Overview
Calculation previews and their CSV exports require theanalytics_calculation_breakdowns feature flag, which defaults to off. Enable it for an organization through the existing feature-flag controls. When disabled, calculation controls are hidden and preview/export requests return 403; dashboard metrics remain available.
The analytics page loads its org-default metrics from a single GET /analytics/dashboard endpoint. The response includes Call-table-backed metrics plus SMS, time saved, dollars saved, and candidate satisfaction. A set of shared CTEs (Common Table Expressions) define base filtering once for SQL-backed metric aggregations.
Individual metric endpoints remain available for other consumers. Campaigns launched and credits per candidate are not part of the org-default dashboard request.
Recruiter analytics routes that use resolve_filters take org scope from the signed-in session (AuthContext.org_id), not from a client org_id query. Job, folder, recruiter, sourced-by, and dashboard/card identifiers stay as real filters and resource IDs. Public API analytics still pass an explicit org from API-key auth through resolve_filters_for_org. /analytics/campaigns_launched_per_week also uses the signed-in organization and job access; its legacy org_id query parameter does not change that scope.
Job access
Analytics automatically includes only jobs you have permission to view. Your existing job grants, folder access, and own-job permissions apply to dashboard metrics, AI chart results, and CSV exports. Sharing a dashboard does not grant access to additional jobs. Job, folder, and recruiter filters can narrow your results further. Clearing those filters returns to all jobs you can access. Selecting an inaccessible job, or having no accessible jobs, returns empty results. The folder/job picker only lists accessible jobs. The legacy credits-per-qualified-candidate metric is available only to users with View all jobs permission because its credit total covers the whole organization.Key Files
Shared CTEs
CallDaoAnalyticsBase provides shared CTE builders that all analytics DAO methods inherit:
All CTEs accept the same filter parameters:
org_id, date_range, campaign_ids, candidate_filter. Queries use self.read_only_session() (the replica when READONLY_POSTGRES_HOST is set, otherwise the primary). The AI-generated SQL path uses self.read_only_tx_session() because it sets SET LOCAL statement_timeout and work_mem, which only apply inside a transaction.
Dashboard Endpoint
GET /analytics/dashboard returns an AnalyticsDashboardResponse containing:
- Scalar metrics: answer rate, call completion rate, total calls, interviews, people contacted, call length, avg interview length, avg time to first interview, opt-outs, qualified candidates, passthrough rate, thumbs up/down
- Chart data: 6 typed time-series charts (calls, candidates, interviews, thumbs up, thumbs down, passthrough rate) — each chart has its own Pydantic model (e.g.
CallsChartResponse,PassthroughRateChartResponse) - Withdrawal & accommodation: withdrawal rate, withdrawal reasons breakdown, accommodation rate
- Sourcing adoption: a typed sourcing object containing total sourced candidates, attributed sourced candidates, Auto-Match count and share, selected-period Total Sourcers, monthly Active Sourcers points, and contribution rows
- Supplementary metrics: SMS total/chart, time-saved total/chart, dollars-saved total/chart, and candidate-satisfaction average/chart
- Metric definitions: 25 built-in metric definitions describing each metric’s identifier, aliases, calculation, grain, date field, source CTEs, and AI generation support
pending_metrics; they keep running and publish their value to a short-lived Redis bundle cache keyed by the request’s filters, and the frontend polls the same endpoint until nothing is pending (bounded). A poll serves cached bundles without re-running them and reports a bundle another worker is still computing as pending rather than starting it again. Callers other than the route (public API, staffing chat, MCP) pass no budget and wait for every bundle. Within the fan-out, SMS responses remain independent from time-saved failures, dollars-saved values reuse the time-saved results, and the satisfaction average and chart come from one aggregate.
Per-Campaign Passing Scores
When filtering to a single campaign, the dashboard usesget_analytics_passing_scores() to load that campaign’s custom passing score from Cosmos. This matches the behavior of the passthrough rate DAO, which also uses per-campaign scores internally.
Frontend
The UI lives underui/src/pages/analytics/. AnalyticsPage.tsx is a thin orchestrator (route sync, filters, permissions, composition). Org-default metric tiles are config-driven via metricTiles/ (named IDs + buildVisibleMetricTileIds). Selecting a supported Overview tile opens one shared AnalyticsBreakdownModal; copy, columns, and stale-tile comparison live in breakdown/breakdownModalContent.ts. Charts share charts/AnalyticsChartSection and are composed by OrgDefaultCharts. AI card actions and dollars-saved settings live in ai/ and dollarsSaved/.
Filters: Candidate Source remains multi-select (same as other surfaces). Selecting both or neither emits no candidate_source param; a single selection narrows results. On filter change, org-default tiles/charts stay mounted and show first-load loaders while detail clears and requests refetch.
Candidate source (backend): resolve_filters applies candidate_source / sourced_by as CCI EXISTS predicates for SQL metrics (not an org-wide pair preload). SMS and candidate satisfaction are Postgres-fact-backed and consume the same CandidateFilter SQL predicate directly — no pair materialization. Remaining Cosmos consumers (notetaking, resume screening time-saved) still call materialize_candidate_filter_pairs on demand via the pair-fetch DAOs.
Data fetching: Orval-generated API clients + SWR. useAnalyticsDashboardData loads the org-default dashboard under a single SWR key (analytics-dashboard-bundle + serialized filters) and makes one GET /analytics/dashboard request. A filter change produces a new cache key; keepPreviousData keeps prior values visible while the new response loads.
useAnalyticsDashboardData takes an enabled flag; the SWR key resolves to null unless enabled && orgId, so the org-default request never fires while viewing a custom/AI dashboard. A failed aggregate request displays one dashboard-level error and is not retried automatically. After dollars-saved assumptions are edited, refreshDollarsSaved fetches the same dashboard endpoint and patches its available dollars fields into the cached response. AnalyticsPage passes enabled: orgDefaultContentVisible (true only when dashboard_type === 'org_default').
Sourcing Adoption Metrics
The organization-default Overview includes three sourcing tiles and two sourcing charts. Their shared sourced population is the repository’s Analytics sourced set:SOURCED, USER_UPLOAD, TEARSHEETS, ALTERNATIVE_ROLE_RECOMMENDATION, and PERMANENT_WEB_LINK_SOURCED. Each row is one distinct candidate/job relationship, dated by candidate_campaign_info.created_at; test candidates and soft-deleted candidate/job relationships or jobs are excluded.
- Sourced Candidates counts every qualifying sourced candidate/job relationship in the selected period. Attribution is not required, so this is the complete sourcing-volume tile.
- Auto-Match Share is Auto-Match attributed sourced candidates divided by all sourced candidates with nonblank attribution, including Auto-Match in the denominator. It returns
0.0%when that denominator is zero. - Total Sourcers counts distinct normalized recruiter attribution emails with qualifying activity during the selected period. With no Date Range, it counts all-time activity. Auto-Match and blank attribution are excluded. Date Range, Job, folder, recruiter, candidate-source, and sourced-by filters all apply. Removed users remain countable because attribution is stored by email.
- Active Sourcers is a monthly line chart. Each point counts distinct normalized recruiter attribution emails with qualifying activity in that month. It uses the same selected scope and exclusions as Total Sourcers; with no Date Range, it returns the all-time monthly series.
- Team Sourcing Contribution shows positive-count attributed candidates by recruiter for the selected period. Auto-Match is a separate non-person team row pinned above people. Human contributors are ordered by count descending, then label ascending; current users display by full name and removed users fall back to their stored email. Unattributed sourced candidates remain in Sourced Candidates but do not appear as a contributor. Selecting a bar opens that contributor’s sourced candidate/job records.
sourcing_analytics: null if this aggregate fails, which the frontend renders as unavailable rather than as zero adoption. A legitimate no-activity result is a populated sourcing object containing zeros, an empty monthly series, and an empty contribution list.
AI Analytics parity
AI Analytics uses the same filters and source population. Sourced Candidates is exact onbase_cci_analytics; Auto-Match Share and Team Sourcing Contribution use the pre-aggregated base_sourcing_contribution_analytics; Total Sourcers and monthly Active Sourcers use base_sourcing_activity_analytics, which applies the selected dashboard period and exposes one distinct normalized human sourcer/month pair. The month field groups and orders the Active Sourcers series. It may additionally narrow the selected dashboard scope when the user explicitly requests whole calendar months, such as “since June”; partial-month boundaries require clarification because the CTE does not expose daily sourcing events. AI percentage SQL follows the shared chart contract by returning Auto-Match Share as a ratio from 0 to 1 with display.value_format=percent; it never multiplies the ratio by 100 because the AI chart renderer performs that conversion, producing the same displayed percentage as the built-in tile.
Custom AI Dashboard Sharing
Custom AI dashboards can be visible to everyone in the org, visible only to the owner, or shared with selected users in the same org. Selected users can view the dashboard and its AI charts, but they cannot rename it, delete it, change its visibility, add charts, or reorder/resize charts. Sharing is only meaningful for private custom dashboards. Org-visible dashboards are already visible to everyone in the org, and the system Overview dashboard follows the org-wide dashboard permission rules instead of per-user sharing.AI card display formatting
Publishing or editing an AI card first runs its query with the selected organization and analytics filters. If the query fails, the card is not saved; an existing card keeps its previous definition. Correct invalid queries and save again. For temporary service failures, retry saving once the service recovers. This check does not repair previously saved cards automatically. AI-generated cards persist their display metadata inside the saved proposed definition. A chart-wide number or percentage format remains the fallback for single-metric cards, while individual numeric result fields can override the format and decimal precision. Field overrides use the exact SQL result alias, so a table can keep candidate and interview totals as numbers while displaying an interview-ratio field as a percentage. Percentage-formatted fields store and query the underlying ratio from zero through one. The frontend multiplies that ratio by 100 only for display. Display metadata does not change SQL access, filters, or metric calculations. Line and bar charts use one shared numeric axis. When a result mixes number and percentage fields, the card renders as a table so neither the count nor the ratio is visually distorted.Custom dashboard loading and layout
Custom dashboard detail responses carry dashboard and card metadata without executing card SQL. Attaching a card and updating card layout also return metadata-only dashboard responses. These operations remain independent of result execution, so loading or arranging a dashboard does not start every card query at once. TheuseDashboardCardResults hook loads card results incrementally. Cards at or near the viewport
become eligible for loading, and the hook requests eligible results in bounded batches. This keeps
the number of concurrent card queries bounded while prioritizing results the user can currently
see or is about to scroll into view.
Successful card results may be reused for up to five minutes through
analytics_card_result_cache.py. The cache key distinguishes the active analytics filters and the
card definition, so changing filters or editing a card requests a separate result instead of
reusing stale data from another filter set or card revision.
Layout changes update card metadata without invalidating already loaded results. The frontend
preserves those results while applying the new card positions and sizes; only cards without a
usable result continue through incremental loading.
AI Analytics Permissions
Anyone who can access Analytics can open AI Analytics and generate a draft chart. Drafts remain local to that user until they are published. On the shared Overview dashboard, only admins and users with org-wide Edit all jobs permission can publish drafts or edit, delete, reorder, or resize saved AI Analytics cards. Custom dashboard owners can publish and manage cards they created on their own dashboards, and can arrange the cards on those dashboards. Cards attached to more than one dashboard require manager permissions to edit or delete. Other Analytics viewers can use the AI assistant and view saved cards, but they cannot change dashboard content they do not own.Individual Endpoints
These endpoints remain available to consumers that need one metric:
SMS sent and candidate satisfaction are both fully Postgres-sourced (
sms_analytics_facts and
candidate_satisfaction_analytics_facts), queried through CallDaoAnalyticsMetrics using the
shared _build_sms_analytics_cte / _build_candidate_satisfaction_analytics_cte builders. sms_sent
counts inbound and outbound messages (same definition as the previous Cosmos-backed metric).
AI custom analytics query the same candidate_satisfaction_analytics_facts projection via
base_candidate_satisfaction_analytics. Notetaking and resume-screening time-saved remain
Cosmos-sourced.
Inspect Answer Rate
Select Answer Rate on the Overview dashboard to see the calls counted as answered. The preview explains the percentage, for example 4 of 10 eligible calls answered, and shows up to 50 answered calls with candidate name, job name, call creation date, last updated date, duration, and status. The date filter uses each call’s last updated date, matching Answer Rate. Answered calls include engaged sessions such as rescheduled calls, as well as completed interviews. Each counted call has its own row, even when the same candidate appears more than once. Choose Download answered calls to download all matching answered calls as CSV. Unanswered attempts contribute to the eligible total but do not appear in this table or download. Your dashboard filters and job access apply to both. The preview identifies how many answered calls are shown out of the full answered count.Inspect Total Calls
Select Total Calls on the Overview dashboard to see the calls behind the number. The preview shows up to 50 of the latest counted call attempts, with the candidate name, job name, call date, duration, and status. Each row is one call attempt. If you called the same candidate more than once, each counted attempt appears separately. The call date is when the attempt was created, and the displayed date includes its timezone. The preview states how many calls you are seeing, for example Showing 50 of 837 calls. Choose Download all 837 calls to download the complete matching set as a CSV. The CSV uses the existing export timestamp format,YYYY-MM-DD HH:MM:SS, without fractional seconds or a timezone suffix. Durations are in seconds.
Your dashboard filters apply to both the preview and download. You can only see calls for jobs you have access to; clearing filters does not expand that access. With no matching calls, the preview shows an empty result and the CSV contains only column headings.
Inspect Qualified Candidates
Select Qualified Candidates to see each qualifying candidate/job record. A call qualifies at 90% question completion with thumbs-up feedback or a score strictly above the passing threshold. The date filter uses each call’s last updated date. Each row is the latest qualifying call for that pair, with completion, score, passing threshold, and feedback.Inspect Passthrough Rate
Select Passthrough Rate to see the passing candidate/job records behind the percentage. The preview states passing count over eligible candidate/job records. A record passes with thumbs-up feedback or a highest call score strictly above that job’s threshold. The date filter uses when the candidate was added to the job. Each row is the highest-scoring call, latest on a tie; call fields are blank when a record passes on feedback without a call. Records that did not pass count toward the total but are not listed.Inspect Candidate Satisfaction
Select Candidate Satisfaction to see the submitted 1–5 feedback scores behind the average. Each row is one feedback record, with candidate, job, score, and submission date. The date filter uses the submission date. The CSV downloads every matching score; message-style free text is not included.Inspect Total SMS’s
Select Total SMS’s to see the messages behind the count. Inbound and outbound messages both appear, and multiple messages with the same candidate stay as separate rows. Each row shows candidate, job, message date, and direction. Message contents are not included.Inspect Sourced Candidates
Select Sourced Candidates to see each sourced candidate/job record. The date filter uses when the candidate was sourced into the job, not call dates. Attribution is not required, so unattributed rows still count. Each row shows candidate, job, sourced-at date, and sourced-by recruiter (or Unattributed / Auto-Match).Inspect Auto-Match Share
Select Auto-Match Share to see the Auto-Match attributed records behind the percentage. The preview states Auto-Match count over attributed sourced candidates. Unattributed sourcing is excluded from the percentage and from the table. The date filter uses sourced-at.Inspect Total Sourcers
Select Total Sourcers to see one row per recruiter email, with the number of candidate/job records they sourced. Blank attribution and Auto-Match are excluded. Removed users still appear by their stored email.Inspect Total People Contacted
Select Total People Contacted to see each contacted candidate/job record. Repeat calls for the same job count once; the table shows the latest call for that pair. Unlike Total Calls, this includes every candidate/job pair in the filtered call history, not only attempted calls. CSV columns label the selected call as the latest relevant call.Inspect Interviews
Select Interviews to see each candidate/job pair where the candidate answered at least one question. Repeat interviews for the same job count once; the table shows the latest started interview for that pair.Inspect Interview Rate
Select Interview Rate to see the interviewed candidate/job pairs behind the percentage. The preview states interviewed count over contacted count. Contacted pairs without a started interview count toward the total but are not listed.CSV Export
Select Dollars Saved to see the Time Saved contributors priced with the organization’s hourly rate and burden. The headline is the same whole-dollar amount as the tile. Each preview row keeps cents and shows hours saved, hourly rate, burden, and dollars saved. When those rounded cents do not add up to the tile, a note under the table reconciles every contributing row, not the 50-row preview. Notes without a complete candidate/job association remain visible. The full CSV also includes the six time-component columns and appends the same adjustment so the dollar column adds up to the aggregate. Org-default charts that have a matching Overview metric also open that same calculation. View calculation on the chart header, or a click on the series, lists the records for the current filters — not the clicked week or month. Team Sourcing Contribution bars still open Sourced Candidates for that contributor. Communication Metrics View calculation lists People Contacted, Total Calls, Total SMS, or Interviews. A matching bar opens the same list. Interviews by Language and the interview heatmap View calculation open the Interviews list for the current filters. They do not narrow to one language or one day-and-hour cell. Metric tiles, calculation headers, and chart tooltips share display units and rounding. Counts and Time Saved hours use whole numbers; Median Time to First Interview shows whole minutes below an hour and whole hours otherwise. Average Interview Length shows completed whole minutes. Rates show one decimal place, except Interview Rate, which shows two; candidate satisfaction shows two decimal places. Dollars Saved displays whole dollars. Display rounding does not change the underlying calculations or the precision of CSV exports. Select Time Saved to see a flat table of candidate/job contributions. Each row shows saved seconds for call handling, call duration, SMS, web-interview links, resume review, notes, and the total. The preview contains the first 50 rows; the CSV includes all contributing rows under the same filters. Notes without a complete candidate/job association remain visible as unattributed contributions. The breakdown explains the tile calculation: 90 seconds per counted call attempt, SMS, created web-interview link, and eligible resume record, plus non-voicemail call duration and note-taking savings. Resume records in eligible resume-enabled jobs are counted without a date condition. The web-link component counts created links, not confirmed email deliveries. The chart uses different resume counting rules, so its buckets may not add up to the tile total. Select Submittals, Hires, or Median Time to First Interview to see the first 50 contributing records and download the full list under the same filters. Submittals lists each submission once, with its submission date, candidate, job, and a matching completed interview. The date filter applies to the submission date. Reused interviews can qualify. Hires lists each qualifying, non-cancelled placement once, with its placement date and a matching completed interview. The date filter applies to the placement date. When several interviews qualify, the explicitly linked interview is preferred for a placement; otherwise the latest qualifying interview is shown. Median Time to First Interview lists phone and web timing records separately, showing the starting timestamp, first qualifying interview start, and elapsed hours. One candidate for one job can contribute both a phone row and a web row. The median uses the complete set of durations. The date filter applies to call updates. Phone starting timestamps come only from calls with a recorded start; web starting timestamps use the earliest matching interview link, even when the link predates the selected range. The shared detail endpoints also acceptmetric=call_completion_rate. Selecting Call Completion Rate opens the same preview. It
returns up to 50 fully completed call rows, completed_calls, started_calls, and
completion_rate rounded to one decimal place. Full completion means question
completion equals 100%; the denominator counts calls with question completion
greater than zero. Call status alone does not determine inclusion. Creation-date
filters apply, repeat calls count separately, and the CSV includes only numerator
calls. No started calls yields a zero rate and a header-only export.
The shared calculation-detail API supports metric=avg_interview_length on
GET /analytics/breakdown and POST /analytics/breakdown/export. Selecting Avg Interview Length
opens the preview: up to 50 call rows, the qualifying call count (interview_calls), and the average
duration in minutes (average_minutes). The CSV contains all qualifying calls with
durations in seconds.
Qualifying calls have at least one answered question (question_completion_rate > 0)
and use call creation dates for filtering. Partial interviews, repeat interviews for
the same candidate/job, and qualifying zero-duration calls remain in the average.
An empty population returns zero calls and a zero average, with a header-only CSV.
The calculation-detail endpoints also accept metric=candidate_satisfaction and
metric=total_sms, and the Overview tiles open those previews. Each preview contains up to 50 records; the CSV contains all
matching records under the same filters.
Candidate Satisfaction returns feedback_count and average_score, with candidate,
job, feedback score, and feedback submission date on each row. It uses the same stored
feedback population and two-decimal average as the dashboard.
Total SMS returns total_sms, with candidate, job, message date, and direction on
each row. Both incoming and outgoing messages count, and multiple messages involving
the same candidate remain separate rows. Message bodies are not included. CSV dates
use YYYY-MM-DD HH:MM:SS. Empty populations produce zero summaries and header-only CSVs.
The calculation-detail endpoints also support the sourcing metrics, and the Overview tiles open those previews:
sourced_candidates: candidate/job rows, sourcing date, and recruiter attribution, including records with no attribution.auto_match_share: auto-matched candidate/job rows, the auto-matched and attributed counts, and the percentage. Unattributed sourcing is excluded from the denominator.total_sourcers: one row per distinct recruiter email, with its sourced candidate/job count. Emails are trimmed and lowercased; blanks and Auto-Match are excluded.
metric=sourced_candidates&sourcing_contributor_email=<email>. Attribution is trimmed
and lowercased to match the chart; use auto-match for its Auto-Match contributor.
The preview count and exported candidate/job records retain all dashboard filters and
job access restrictions. Each row must itself have the selected attribution. This
parameter is only supported for sourced_candidates; blank values are rejected. The
Sourced Candidates tile still opens the unfiltered list.
The calculation-detail endpoints support people_contacted, interviews, and
interview_rate with one row per candidate/job pair. People Contacted shows the latest
call within the filters. Interviews and Interview Rate show the latest call within the
filters where at least one question was answered. A later unanswered call does not
replace that interview call. Latest is determined by call creation date, with call ID
breaking ties. The returned call ID, status, duration, and timestamps all refer to the
selected call; CSV columns are labeled Latest Relevant Call.
Interview Rate returns the interviewed and contacted pair counts and a percentage
rounded to two decimals. Its rows are the interviewed pairs. Multiple calls for one
candidate/job count once, while the same candidate in another job counts separately.
The same detail endpoints accept outside_business_hours, withdrawal_rate,
accommodation_rate, failed_knockout_rate, drop_off_rate, and opt_out_rate,
and the Overview tiles open those previews. These responses contain
numerator_count, eligible_count, rate, and contributing rows.
Select Interviews Outside Business Hours to see completed calls outside
Monday–Friday, 9am–5pm in the candidate’s timezone. Calls missing a start or
timezone are excluded from both counts. Rows include candidate-local start time
and timezone.
Select the other five rate tiles to see one row per counted candidate/job pair,
using the latest contributing call’s status, duration, and dates. Withdrawal adds
its recorded reason and current stage; Accommodation uses the latest call linked
to the active request; stage-based outcomes include current stage. Opt-Out
excludes pairs with a completed or failed-knockout call within the filters. These
details preserve each metric’s existing denominator and show only its numerator
records. Preview is limited to 50 rows; CSV includes all rows. Pair-metric CSV
columns are labeled Latest Relevant Call.
The same endpoints accept qualified_candidates and passthrough_rate, and the
Overview tiles open those previews. Qualified Candidates lists qualifying
candidate/job records with the latest qualifying call, completion, score, passing
threshold, and feedback. Score is shown as a whole number in the preview and as a
whole percentage in the CSV, matching Interviews and the candidate analytics export.
The stored grade stays at full precision for qualification calculations. Passing score
is the job threshold as a whole number (for example 60). Passthrough Rate lists
passing records with both counts used in the percentage; call fields are blank when a
record passes on feedback without a call. Preview is limited to 50 rows; CSV includes
all matching rows.
POST /analytics/export (and Public API GET /api/v1/analytics/export.csv) streams one row per
candidate-job pair matching the current analytics filters.
The Comments column contains all comments for the candidate-job pair, separated by line breaks.
Comments are not split across numbered columns or truncated. Interviews-page bulk exports use the
same single Comments column.
Tenzo Stage is the current Interviews-table stage (candidate_campaign_info.tenzo_stage),
formatted with the same labels as the UI (for example Outreach, Call Completed, Opted Out). The
listing query already loads that column, so adding it does not add a database round-trip or join
beyond the existing batched candidate fetch.
Adding a New Filter
A new filter only needs 3 touch-points — no per-metric changes:1
Parse in resolve_filters
Add the query param to
server/analytics/analytics_filters.py and include it in ResolvedFilters.2
Apply in one CTE builder
Add a
where clause in the relevant CTE method in server/dao/call_dao/call_dao_analytics_base.py. All metrics using that CTE now respect it automatically.3
Pass through in dashboard endpoint
No per-metric logic needed — the dashboard passes
filters to each DAO method, which passes them to the CTE builder. If the CTE uses it, all metrics respect it.