Collector operations runbook
On-call steps for CX Orbit ingest: rejects, duplicates, lag, and collector outage.
Companion: ../INTEGRATION-GUIDE.md (instrument + staging validate). Wire contract: ../SDK-AND-DATA-INGESTION.md §11. Failure ownership: ../adr/0002-collector-sdk-outbox.md §5.
Rule: no silent drop. Every processed batch item has a results[] row. Invalid items are quarantined when they can be sanitized. Duplicates are success, not loss.
0. Surfaces (use these, not tribal knowledge)
| Surface | What it shows | Auth |
|---|---|---|
GET {collector}/health |
Process up, environment, collector_version |
none |
GET {collector}/metrics |
accepted, rejected, quarantined, duplicate, rate_limited, auth_failures, late_events, latency_ms, event_lag_ms, quarantine.{pending,replayed,discarded} |
none |
POST {collector}/v1/events:batch |
Per-item outcome / code / retryable / quarantine_id |
Bearer write key |
GET {collector}/v1/debug/store |
In-memory raw/canonical key refs (no property dump) | Bearer write key |
Web /dq |
Missingness, lag, rejects, duplicates, queue/retry vs T1.10 / §13 targets | none on current skeleton (staff Auth.js is T5.5) |
GET {web}/api/dq |
Same snapshot as JSON | none on current skeleton |
GET {web}/api/health |
Web process up | none |
| Reconcile job | Completeness vs docs/fixtures/staging/source-records.json |
write key (reads debug store) |
Local defaults: collector http://127.0.0.1:8080, web http://localhost:3000. Staging: $COLLECTOR_PUBLIC_URL, $WEB_PUBLIC_URL (../deploy/railway-staging.md).
/dq polls /metrics every 30 seconds. Completeness and canonical duplicate rate come from the last reconcile report JSON (RECONCILE_REPORT_PATH, default docs/fixtures/dq/last-reconcile-report.json).
Pilot targets (fail the gate if breached) — source of truth DATA-QUALITY-BAR.md (T1.10):
| Measure | Target |
|---|---|
| P0 completeness vs source | ≥ 99% |
| Duplicate rate after canonicalization | ≤ 0.1% |
| Valid accept latency | p95 < 5s (dashboard v0 uses max as proxy) |
Event lag (received_at − occurred_at) |
< 24h for P0 operational |
| Invalid P0 visible | within 15 min (counters on /metrics) |
1. First 2 minutes (any ingest page)
set -a && source .env && set +a
COLLECTOR="${COLLECTOR_URL:-http://127.0.0.1:8080}"
WEB="${WEB_PUBLIC_URL:-http://localhost:3000}"
curl -sS -o /tmp/col-health.json -w "collector_http=%{http_code}\n" "$COLLECTOR/health"
curl -sS -o /tmp/web-health.json -w "web_http=%{http_code}\n" "$WEB/api/health"
curl -sS "$COLLECTOR/metrics" | jq '{
accepted, rejected, quarantined, duplicate,
rate_limited, auth_failures, late_events,
latency_ms, event_lag_ms, quarantine
}'
curl -sS "$WEB/api/dq" | jq '{pilotGate: .view.pilotGate, widgets: [.view.widgets[] | {id, actual, status}]}'
| Observation | Go to |
|---|---|
Collector health not 200 / not status=ok |
§5 outage |
rejected / quarantined climbing |
§2 rejects |
duplicate high and /dq duplicates fail |
§3 dupes |
event_lag_ms.max ≥ 24h or ingest latency_ms.max ≥ 5s |
§4 lag |
rate_limited climbing |
§2.4 / §5 (shed load) |
| Health OK, metrics flat, Simiaroom says events missing | §3 missingness / §5 (SDK pointing at wrong URL/env) |
Fixture drill (widgets move without live traffic): /dq?fixture=reject, dup, lag, missing.
2. Troubleshoot rejects / quarantine
Rejects are permanent unless retryable: true (item-level transients are not used in MVP). Do not hammer the collector with the same bad payload.
2.1 Get the code
From the producer flush() / HTTP body results[]:
code |
Meaning | Owner | Action |
|---|---|---|---|
UNKNOWN_EVENT_TYPE |
Type not in tracking plan | Simiaroom + Orbit data | Add contract/schema (integration guide §3) or stop emitting |
MISSING_REQUIRED_FIELD |
Envelope field missing | Producer | Fix payload; path names the field |
SCHEMA_VALIDATION_FAILED |
Per-type schema | Producer | Fix properties; check path |
IDENTITY_REQUIRED |
P0 missing identity.user_id |
Producer | Do not emit until user is known |
ENVIRONMENT_MISMATCH |
Envelope environment ≠ collector CX_ORBIT_ENV |
Producer / deploy | Point SDK at the matching collector or fix environment |
PROJECT_MISMATCH |
project_id ≠ simiaroom |
Producer | Fix project_id |
AUTH_MISSING / AUTH_INVALID |
HTTP 401, no ingest | Producer / secrets | Rotate/set COLLECTOR_SERVER_WRITE_KEY; do not retry until fixed |
EVENT_CLASS_NOT_PERMITTED |
Browser key used for a P0 / non-allowlisted class | Producer | Use @cx-orbit/node + server key for P0; browser key is P1 behavioral only |
PRIVACY_VALIDATION_FAILED |
Disallowed field | Producer | Strip; never send clinical text |
UNSUPPORTED_EVENT_VERSION |
event_version not accepted |
Orbit schema | Support window or bump producer |
CANONICAL_TYPE_MISMATCH |
canonical_type ≠ event_name |
Producer | Send one name, or make them equal |
IDEMPOTENCY_CONFLICT |
Same canonical key, conflicting body | Joint | Do not overwrite. Compensating event with a new source_event_id |
EVENT_ID_REUSED |
Same event_id, different body |
Producer | Keep first envelope; new fact needs a new event_id |
2.2 Confirm visibility (15-minute SLO)
curl -sS "$COLLECTOR/metrics" | jq '{rejected, quarantined, quarantine}'
# /dq rejects widget: actual "rejected N; quarantined M", status warn if N or M > 0
There is no public quarantine-payload HTTP inspector. Diagnosis is:
- Ingest
results[](code,path,message,quarantine_id) — messages contain no PII. /metrics+/dqcounters.- Collector process logs (Railway: service
collector).
Do not paste write keys, payloads with emails/phones, or therapy content into tickets.
2.3 Replay after a schema/producer fix
Replay is an ops action. SDKs must not auto-retry quarantined schema errors.
1. Fix producer, schema, or decide discard
2. Re-POST the corrected envelope to POST /v1/events:batch
(same write key, validation, privacy, dedup as live traffic)
3. Keep original event_id and source_event_id when those were valid
4. Success → quarantine.replayed increments; original row is not deleted
5. IDEMPOTENCY_CONFLICT as-is is refused — never replay to overwrite
Covered by collector contract tests CT-17 / CT-18 (pnpm --filter @cx-orbit/collector test).
2.4 Rate limit (HTTP 429)
Default 120 requests/minute/write key (COLLECTOR_RATE_LIMIT_PER_MINUTE). Response includes Retry-After and retryable: true. SDK honors this. /dq queue widget shows rate_limited.
If 429s persist: split batches (max 100 events / 256 KB), slow the outbox relay, check for a retry storm of already-accepted items (safe but noisy — filter accepted/duplicate from the queue).
3. Troubleshoot duplicates
Duplicates on ingest are success (outcome: duplicate). Clients must not retry them and must not page as “data loss.”
code |
What happened | Canonical facts |
|---|---|---|
DUPLICATE_EVENT_ID |
Same event_id resent (HTTP retry / batch replay) |
Unchanged (one raw row) |
DUPLICATE_CANONICAL_KEY |
New event_id, same project_id+source+source_event_id, compatible body |
No second fact |
When duplicates are a problem: /dq duplicates widget fail, or reconcile duplicate_rate_pct > 0.1%. That means extra canonical facts (or a bad report), not healthy transport retries.
# Collector transport/canonical duplicate counter (process lifetime)
curl -sS "$COLLECTOR/metrics" | jq '.duplicate'
# Canonical vs source (needs events in this collector process)
set -a && source .env && set +a
# Script name is `run`; `pnpm … run` only lists scripts (exit 0). Use `run run`.
pnpm --filter @cx-orbit/reconcile-job run run
# inspect duplicate_rate_pct, raw_count, observed_count, mismatches
Reproduce locally:
# emit twice against the same collector process
pnpm --filter @cx-orbit/producer-stub emit
pnpm --filter @cx-orbit/producer-stub emit
# second flush: duplicate ≈ 12, accepted ≈ 0; debug store canonical_count still 12
If IDEMPOTENCY_CONFLICT appears: two producers (SDK + CDC, or confirm vs cancel) reused source_event_id with different material fields. Fix source_event_id construction (SDK-AND-DATA-INGESTION.md §7.4). Do not delete the first canonical row.
Missingness (completeness < 99%, missing_in_orbit): producer never emitted, wrong source/source_event_id, collector restarted (in-memory store empty), or SDK pointed at another environment. Re-emit from stub/outbox; re-run reconcile.
4. Troubleshoot lag
Two clocks:
| Metric | Definition | Target | Source |
|---|---|---|---|
| Ingest latency | Time to process the HTTP batch | p95 < 5s (v0: latency_ms.max) |
/metrics |
| Event lag | received_at − occurred_at |
P0 < 24h | /metrics event_lag_ms, late_events |
Late but valid events are stored (accepted_with_warnings / LATE_EVENT) — not dropped.
curl -sS "$COLLECTOR/metrics" | jq '{latency_ms, event_lag_ms, late_events}'
| Pattern | Likely cause | Action |
|---|---|---|
Ingest max ≥ 5s |
Collector CPU, huge batches, host saturation | Check Railway collector metrics; split batches; §5 if health is failing |
| Event lag ≥ 24h, ingest fast | Producer used old occurred_at (fixtures, backfill) or outbox delay |
Confirm occurred_at is the business time; backfill is allowed (7-day import threshold) but flagged |
| Completeness falling while lag rising | Collector down or SDK retrying | §5 |
/dq lag unknown |
COLLECTOR_PUBLIC_URL unset / collector unreachable from web |
Fix web env; collector can still be ingesting |
Staging fixtures in docs/fixtures/events/*/valid.json use 2026-08-03 timestamps; emitting them now will increment late_events. That is expected for those fixtures, not an outage.
5. On-call: collector outage
Simiaroom booking / payment / session must keep working. Orbit ingest is best-effort after commit (ADR 0001 / 0002). Do not “fix” an outage by blocking checkout.
5.1 Severity
| Signal | Severity |
|---|---|
/health down or HTTP 5xx on /v1/events:batch for > 1 min |
P1 ingest outage |
| Intermittent 503 / 429 with SDK retries succeeding | P2 — watch queue depth |
| 401/403 only | Not an outage — secrets/config (do not retry) |
5.2 Confirm
COLLECTOR="${COLLECTOR_URL:-http://127.0.0.1:8080}"
curl -sS -w "\nhttp=%{http_code}\n" "$COLLECTOR/health"
# Staging Railway:
# curl -sS -w "\nhttp=%{http_code}\n" "$COLLECTOR_PUBLIC_URL/health"
# Optional: authenticated probe (expect 2xx or 422, not connection refused)
curl -sS -o /dev/null -w "batch_http=%{http_code}\n" -X POST "$COLLECTOR/v1/events:batch" \
-H "Authorization: Bearer $COLLECTOR_SERVER_WRITE_KEY" \
-H "Content-Type: application/json" \
-d '{"events":[]}'
# empty events → HTTP 400 BATCH_SCHEMA_INVALID if the process is up
HTTP 503 COLLECTOR_UNAVAILABLE is the load-shed / forced-unavailable shape (retryable: true). Drill locally:
COLLECTOR_FORCE_UNAVAILABLE=true pnpm --filter @cx-orbit/collector dev
# POST /v1/events:batch → 503, code COLLECTOR_UNAVAILABLE, retryable true
Railway collector: healthcheck path /health (apps/collector/railway.toml), restart policy ON_FAILURE. Check the collector service logs and last deploy. Web can be up while collector is down — /dq lag/rejects widgets go unknown.
5.3 What Simiaroom should do (producer)
| Failure | Behavior |
|---|---|
| 408 / 429 / 502 / 503 / 504 / network | SDK retries with backoff (1s × 2^n, jitter, 60s cap, 10 attempts or 15 min). Same event_id. |
| In-memory queue (no outbox) | May drop on overflow (stats().dropped). Page if P0 leaked onto this path. |
| Outbox (T2.14) | Rows stay pending; pnpm --filter @cx-orbit/outbox relay -- --store <path> when /health is ok. |
| 401 / 403 / 422 | Stop retrying that payload; alert humans. |
@cx-orbit/node flush() returns failed: true on transport failure — treat as visible, not success.
5.4 What Orbit on-call should do
- Do not tell Simiaroom to pause bookings.
- Confirm Railway
collectorvswebvs Postgres independently (collector deploys alone). - If a bad deploy: roll back / redeploy
collectoronly (../deploy/railway-staging.md). - If
COLLECTOR_FORCE_UNAVAILABLE=trueleft on: unset and restart. - If write-key rotation mid-flight: 401s look like an outage to producers — publish the new key to Simiaroom staging/prod secrets before disabling the old one when possible.
- Postgres down does not currently fail HTTP ingest (in-memory store). Still fix DB for web/jobs; do not assume ingest is durable across collector restart.
5.5 After collector is healthy
curl -sS "$COLLECTOR/health" | jq .status # ok
set -a && source .env && set +a
pnpm --filter @cx-orbit/producer-stub emit
# Script name is `run`; `pnpm … run` only lists scripts (exit 0). Use `run run`.
pnpm --filter @cx-orbit/reconcile-job run run
# expect within_dq_target true if this process received the slice
curl -sS "${WEB_PUBLIC_URL:-http://localhost:3000}/api/dq" | jq '.view.pilotGate'
Because the store is in-memory, a restart looks like total event loss until producers retry or you re-emit. That is expected for the collector process; it is not a reason to rebuild canonical facts by hand. Outbox relay (T2.14) is the durable catch-up path for agreed P0: OUTBOX-RELAY.md. CDC (L3) covers missed instrumentation.
If Simiaroom outbox replayed the same envelopes: expect DUPLICATE_EVENT_ID / DUPLICATE_CANONICAL_KEY — success.
5.6 Escalate / residual
| Gap | Impact during outage |
|---|---|
Simiaroom product DB not yet wired to cx_outbox_events |
File-store / stub covers the Orbit repo; live Simiaroom must INSERT in the same TX |
| Collector not persisting HTTP ingest to Postgres | Restart wipes raw/canonical/quarantine; outbox replay re-delivers |
| No quarantine inspector UI | Use results[] + counters only |
| T4.12 reconciliation review | RECONCILIATION-REVIEW-T4.12.md — fixture/staging window; T4.1 live still open |
Page Orbit platform for collector 5xx; page Simiaroom eng for 4xx schema/auth and outbox depth. Joint triage for IDEMPOTENCY_CONFLICT and source-vs-Orbit count mismatch.