Performance Hub Operator Runbook
Operational reference for SREs and on-call engineers responding to issues in the Agent Performance Hub (spec 27) — the nightly composite-score snapshot pipeline, supervisor alerts, formal reviews, and improvement plans.
Audience. SRE / on-call. Assumes shell access to the production server (
serge@clear) and a workingpsqlsession against the production database. For end-user docs (HR walkthroughs of the dashboard) see/settings/performanceand/performancein-app.
Quick reference — service map
| Component | Container | Notes |
|---|---|---|
| API | production-api-1 / staging-api-1 | Routes under /api/v1/perf-hub/* |
| Worker (BullMQ) | production-worker-1 / staging-worker-1 | Runs perf-hub-snapshot + perf-hub-review-period-end + perf-hub-plan-check-in queues |
| Postgres | production-postgres-1 | Tables: perf_hub_snapshots, perf_hub_configs, perf_hub_goals, perf_hub_reviews, perf_hub_improvement_plans, plus the shared worker_cron_completions ledger |
| Redis | production-redis-1 | BullMQ queues + scheduled-job state |
Cron-chain cadences
The Performance Hub upstream + its CSAT downstream form a chain. Times are
UTC. Each downstream requireUpstream() gate refuses to start until the
upstream marker is fresh (see services/worker/src/lib/cron-chain.ts).
| Job | Cadence | Upstream gate |
|---|---|---|
perf-hub-snapshot | 0 2 * * * (02:00) | — (chain head) |
csat-score-snapshot | 30 2 * * * (02:30) | perf-hub-snapshot |
csat-alert-evaluation | 0 3 * * * (03:00) | csat-score-snapshot |
csat-coaching-trigger | 15 3 * * * (03:15) | csat-score-snapshot |
perf-hub-review-period-end | hourly | perf-hub-snapshot |
perf-hub-plan-check-in | hourly | perf-hub-snapshot |
The status panel
HR admins (and anyone with perf_hub.config.manage) see a "Snapshot pipeline"
panel at the top of /settings/performance — your first stop on a triage call.
It reads GET /api/v1/perf-hub/operational-status and renders:
- Freshness pill —
fresh≤ 26h,stale≤ 48h,critical> 48h,never_ranif no marker - Tenant coverage —
snapshotted_yesterday÷active_agents - CSAT coverage — fraction of yesterday's rows with
score_csat IS NOT NULL - Anomalies — tenant-scoped flags from the M3 detector (see below)
- Downstream chain — per-queue freshness, if more than one queue has ever marked completion
If the panel itself fails to load, the API or DB is down — pivot to general
service health (/health/ready).
Failure mode: snapshot freshness drops
stale (>26h since last marker, ≤48h)
A single missed run. Usually a runner restart, deploy window, or BullMQ stall.
- Check the worker is up:
ssh serge@clear "docker compose -p production ps worker" - Look at the most recent run attempt in the worker logs:
ssh serge@clear "docker logs production-worker-1 --since 36h 2>&1 | grep -E 'gamification_points_award|perf_hub' | head -50" - If the job never started, restart BullMQ scheduling by bouncing the worker:
ssh serge@clear "docker compose -p production restart worker" - After the restart, manually trigger today's run rather than waiting until 02:00:
ssh serge@clear "docker compose -p production exec worker node --import tsx -e \"
import { Queue } from 'bullmq';
const q = new Queue('perf-hub-snapshot', { connection: { host: 'redis', port: 6379 } });
await q.add('manual', {}, { jobId: 'manual-' + Date.now() });
await q.close();
\""
critical (>48h)
Two or more consecutive missed runs. Page on-call. While waiting for
acknowledgment, run the same diagnostic chain as stale — most critical
states resolve to the same root cause (worker container dead) caught later.
never_ran
A fresh tenant onboarding or a wiped DB. Confirm against
worker_cron_completions:
SELECT queue_name, last_completed_at FROM worker_cron_completions ORDER BY queue_name;
If the row is genuinely missing, the worker hasn't completed a single production run yet. Trigger one manually (snippet above).
Failure mode: tenant coverage drop
Coverage band guide
coverage_pct | Tone | Interpretation |
|---|---|---|
| ≥ 95% | Green | Normal. A few inactive employees getting missed is expected. |
| 75–95% | Amber | Worth checking. Likely a recent batch of terminations not yet reflected in employees.status or a planner working off stale assignments. |
| < 75% | Red | Investigate. Probably a regression in the active-employee enumeration query. |
Diagnosis
The snapshot worker enumerates active employees with this shape:
SELECT e.tenant_id, e.id, eph.client_lob_id
FROM employees e
JOIN employee_profile_history eph
ON eph.employee_id = e.id AND eph.effective_to IS NULL
WHERE e.status = 'active';
Compare the candidate count to the snapshot count for the same date:
WITH candidates AS (
SELECT count(*) AS n FROM employees e
JOIN employee_profile_history eph ON eph.employee_id = e.id AND eph.effective_to IS NULL
WHERE e.tenant_id = '<TENANT>' AND e.status = 'active'
), snapshots AS (
SELECT count(*) AS n FROM perf_hub_snapshots
WHERE tenant_id = '<TENANT>' AND snapshot_date = CURRENT_DATE - 1
)
SELECT candidates.n AS candidates, snapshots.n AS snapshotted,
round(100.0 * snapshots.n / NULLIF(candidates.n, 0), 2) AS pct
FROM candidates, snapshots;
If candidates - snapshotted is large, look at the worker's errored counter
in the last run metadata:
SELECT last_run_metadata FROM worker_cron_completions WHERE queue_name = 'perf-hub-snapshot';
A non-zero errored count means per-employee exceptions during the loop
(each one logs at error level with the offending employee_id).
Failure mode: anomalies fired
The M3 detector runs after the per-employee UPSERT loop and writes any
tenant-level anomalies into worker_cron_completions.last_run_metadata.anomalies.
The status panel surfaces them as an amber callout.
Three kinds:
row_count_drop
Today's row count is > 30% below the prior snapshot date.
Likely causes:
- A bulk-termination event yesterday that the snapshot picked up correctly — confirm by checking
employees.statuschanges - Employee profile history rows mass-expired (effective_to filled in) — check
employee_profile_historyfor unexpectedeffective_totimestamps - The active-employee enumeration query regressed (look at the worker's recent diffs)
Recovery: none required if the cause is legitimate (terminations). Otherwise, fix the source and the next nightly run clears the flag.
median_score_drop
Today's median composite is > 15pts below yesterday's.
Likely causes:
- An upstream rollup miscounted — most often the QA dimension (
qa_evaluations.total_score_pctaggregation). Check the QA module's own status. - A planner published a "what-if" schedule mid-day that the attendance dimension misread — check
workforce_shift_assignmentsfor unexpecteddraft_assignedrows - Weight configuration changed and the new weights heavily penalize a previously-dominant dimension — check
audit_eventsfor recentperf_hub.config.updated
Recovery: verify the upstream is sound, re-run the snapshot. The idempotent UPSERT replaces yesterday's row.
null_composite_spike
More than 25% of today's rows have NULL composite_score.
Likely causes:
- Every dimension is NULL for many agents — usually means the source-data workers (QA, training, adherence) haven't run or are emitting empty rows
- The tenant's weights are misconfigured such that every scored dimension
carries weight 0 — the worker already logs
composite_score null despite populated dimensionsfor this; check the worker logs
Recovery: fix the upstream, re-run. Same idempotency story.
Failure mode: CSAT coverage stuck at 0
The status panel's CSAT card shows 0%. The score_csat column in
perf_hub_snapshots is the join target — if it's all NULL, either CSAT
itself isn't running or the CSAT chain gate (csat-score-snapshot's upstream
requirement on perf-hub-snapshot) is tripping.
- Check the CSAT marker:
SELECT * FROM worker_cron_completions WHERE queue_name = 'csat-score-snapshot'; - If missing or stale, look at the CSAT worker log:
ssh serge@clear "docker logs production-worker-1 --since 36h 2>&1 | grep csat-score-snapshot | tail -50" - If the chain gate is tripping, that means
perf-hub-snapshotfailed and CSAT correctly refused to start. Resolve the upstream first. - If CSAT did run but coverage is still 0%, verify the snapshot table has agent-scope rows:
SELECT count(*) FROM csat_score_snapshots
WHERE tenant_id = '<TENANT>'
AND agent_employee_id IS NOT NULL
AND insufficient_data = false
AND computed_at >= now() - interval '24 hours'; - The PH snapshot reads the latest ROLLING_30D row per agent via
scoreCsat()inperf-hub-snapshot.ts. If CSAT has the data but PH isn't picking it up, the cron ordering is broken — verifyperf-hub-snapshotruns at 02:00 andcsat-score-snapshotat 02:30 (PH reads yesterday's CSAT rows; if CSAT runs first on a given day, that day's PH still picks up the previous-day CSAT — that's by design).
Recovery: backfill missing days
When the snapshot pipeline has been down for multiple days, use the backfill script. It walks any date range and invokes the production handler once per day, with alerts suppressed (so a 30-day backfill doesn't fire 30 supervisor notifications).
ssh serge@clear "docker compose -p production exec worker pnpm backfill:perf-hub --from 2026-05-01 --to 2026-05-10"
The handler's UPSERT is idempotent — re-running the backfill over already-
populated dates is safe. The cron-chain marker is NOT written by backfill
runs (they pass opts.targetDate, which skips markJobCompleted) so the
freshness pill keeps reflecting the real most-recent live run.
Diagnostic queries cheat sheet
Composite score distribution for a tenant on a given date
SELECT percentile_cont(ARRAY[0.10, 0.25, 0.50, 0.75, 0.90])
WITHIN GROUP (ORDER BY composite_score) AS percentiles,
count(*) AS n,
count(*) FILTER (WHERE composite_score IS NULL) AS null_n
FROM perf_hub_snapshots
WHERE tenant_id = '<TENANT>' AND snapshot_date = '<YYYY-MM-DD>';
Which agents are missing from yesterday's snapshot?
WITH expected AS (
SELECT e.id AS employee_id
FROM employees e
WHERE e.tenant_id = '<TENANT>' AND e.status = 'active'
), got AS (
SELECT employee_id FROM perf_hub_snapshots
WHERE tenant_id = '<TENANT>' AND snapshot_date = CURRENT_DATE - 1
)
SELECT e.employee_id FROM expected e
LEFT JOIN got ON got.employee_id = e.employee_id
WHERE got.employee_id IS NULL;
Show the chain status all at once
SELECT queue_name,
last_completed_at,
round(extract(epoch from now() - last_completed_at) / 3600, 2) AS age_hours,
last_run_metadata
FROM worker_cron_completions
ORDER BY queue_name;
Watch the most recent anomaly fan-out across tenants
SELECT jsonb_array_elements(last_run_metadata -> 'anomalies') AS anomaly
FROM worker_cron_completions
WHERE queue_name = 'perf-hub-snapshot';
Escalation
- On-call rotation:
#frontline-oncallSlack channel; primary rotates weekly. - Spec questions: Engineering owns the implementation. HR Operations owns the score weights and the improvement-threshold policy.
- Data integrity (anomalies that persist > 2 runs): escalate to the Engineering tech lead — a persistent anomaly is a real regression, not a one-off blip.