AGENTS.md@packages/omo-senpi/src/components/telemetry · git:20261028.5f3366c · 2026-10-28 · sha256 908e3b0705c08507
AGENTS.md@packages/omo-senpi/src/components/telemetry git:20261028.5f3366cA
Immutable. This exact content never changes and is served at /api/v1/blob/908e3b0705c08507.
# telemetry component
Senpi telemetry adapter over `@oh-my-opencode/telemetry-core` PostHog primitives. Two products share this directory: the legacy `omo-senpi` daily-active ping (`index.ts`) and the richer `omo-native` event family (everything `omo-native-*`). Both are anonymous, best-effort, and must never block or crash the host.
## Anatomy
| Path | Purpose |
|------|---------|
| `index.ts` | Legacy daily-active: fires `omo_senpi_daily_active` on `session_start`, once per UTC day per machine, hard `withTimeout` cap at 500 ms (`DEFAULT_TIMEOUT_MS`), failures logged at debug only. State under `<agent-home>/omo-senpi/posthog`. |
| `product-identity.ts` | `omo-native` product config, PostHog write key constant, `KNOWN_MODELS`/provider allowlists, salted `hashSessionId` (sha256 over a persisted 32-byte 0600 salt), `maskProviderAndModel`; re-exports the schemas from `event-schemas.ts`. |
| `omo-native-component.ts` | Composition root: shared transport factory, capture fan-in gated by the consent gate (read before every forwarded event) and `isOmoNativeEventName`, wires session/prompt/tools/turns/notice/parallel-summary registrations. Registration order is load-bearing (see parallel-summary). |
| `omo-native-session.ts` | `session_started` event: reason, os/arch/cpu/memory bucket, provider and model inventory (masked to known lists or `custom`). Also chains the legacy `recordSenpiDailyActive`. |
| `omo-native-notice.ts` | Once-per-machine `notice-shown` marker + visible one-line disclosure: what is sent, PostHog in the United States, the `DO_NOT_TRACK=1` and `telemetry.enabled: false` opt-outs, and the privacy policy URL. |
| `omo-native-prompt.ts` | `prompt_submitted`: ultrawork classification, buckets for length and ordinal, suppression reasons. Raw prompt text never leaves the process. |
| `omo-native-tools.ts` | `skill_loaded` (builtin skills only, path-checked against the shipped skills root), `delegation_started`, `feature_used` (deduped per session). |
| `omo-native-computer-use.ts` | Process-shared adapter observer -> `computer_use_activation`, `computer_use_permission_denied`, and `computer_use_engine_error`; hashes the owning session and accepts only closed platform/backend/permission/error vocabularies. |
| `omo-native-handoff-hint.ts` | Senpi's `handoff_hint` event-bus payload (senpi#3230) -> `handoff_hint` (`shown`/`accepted`/`dismissed`), strict payload parse, masked provider/model, hashed session; detached on shutdown. |
| `omo-native-turns.ts` | `turn_completed` from assistant `turn_end`: token and cost numbers, provider/model masked. Every turn feeds the session summary; the raw row ships only for session-hash-sampled sessions, with `sample_rate`. |
| `omo-native-turn-span.ts` | One `omo.turn` span per assistant turn (`turn_start` -> `turn_end`; `error` for a failed message or a turn abandoned at `agent_end`) with the D-P attributes, only when the live transport has a span sink (collector). A turn still open at `session_shutdown` ends as `error` with `omo.turn.end_reason = shutdown` from a handler registered before the session component's (so before the transport shuts down). Every ending exports the span (turn end, abandon, a turn replaced by the next `turn_start`, shutdown), so each one reads consent fresh at that moment (`isSendAllowed`, the send-time read); after a withdrawal the span is dropped unended and its traceparent is still cleared. Per registration, never per component: every event the session emits runs inside the open span, so its record carries the pair. Publishes the turn's traceparent for the task engine (`traceparentFor`) for the life of the turn only: it is cleared when the turn ends, is abandoned, or is replaced, so after a turn end or an opt-out no later launch carries a stale trace id; a process child reads it back via senpi-task `readSessionTraceparent` (env `OMO_SENPI_TASK_TRACEPARENT` or daemon session context `traceparent`). In-process children load no extensions and emit no spans; a respawned/resumed child starts its own trace. |
| `omo-native-session-summary.ts` / `session-summary-window.ts` / `session-summary-schema.ts` | `session_summary`: per-session turn rollup, checkpointed every 50 turns / 30 min, on reload and at shutdown, integer `cost_1e4` sums, `dropped_events`. Same shutdown registration-order constraint as the other summaries. |
| `session-summary-storage.ts` / `-files.ts` / `-owner.ts` | Durable state under `<state>/session-summary/`: per-session window files (owner pid + boot id, heartbeat, persisted seq, window id), `pending/<window id>.json` events awaiting acknowledgment, the `owners/<pid>.json` registry (boot id, the writer's process start time from `session-summary-process-start.ts`, and a heartbeat refreshed on every 60 s heartbeat tick, open window or not, and at most once a minute) that tells a live owner from a reused pid: a live pid whose start time differs from the recorded one is a dead owner; where no start time can be compared (Windows, an older entry) a live pid with a matching entry counts as alive, however old its heartbeat (an aged heartbeat proves a stalled timer, not a death; the cost is that a dead owner's window waits while an unrelated process holds its pid). Each window record carries the `summary_epoch` its `summary_seq` counts in: random per process run (`newSummaryEpoch`, 16 hex chars), kept only while the process that wrote it continues the file (a reload, its own file), new for anything else (a claimed dead owner's window, a recovered window, an own copy beside a live window, a lost claim, an opt-in after an opt-out), so a pair never repeats even when liveness misjudges a live owner. Only ESRCH from `kill(pid, 0)` reads a pid as dead (EPERM and any other answer count as alive), and an entry is removed only while it is still the one judged, so a process that took the pid meanwhile keeps its own. The collector deduplicates on (session, `summary_epoch`, `summary_seq`), so processes never coordinate seqs and an opt-out leaves no file behind. Every write is temp + rename; every hand-over is a rename claim. Every window and pending record carries its originating session's `consent` mark and consent `scope` (cwd). Consent (`isOmoNativeTelemetryEnabled`) is read at registration and fresh before every write (no cache: one omo.json load is under a millisecond, and a cached read let a turn right after an opt-out persist a window); an opted-out registration creates no summary state of its own and at once runs the opt-out purge (`session-summary-optout-start.ts`; shutdown runs it again): first, in the entry bundle with node:fs only, `purgeUnconsentedQueue` (`session-summary-purge.ts`) deletes every window and pending event that may not be sent, whoever wrote it, a live owner's included; then, only when an interrupted write's `.tmp` or a `.claimed-` copy remains, the recovery sidecar's `purgeOptedOutQueue` (`session-summary-optout-purge.ts`) judges owner liveness and drops dead owners' leftovers and registry entries. Both read consent as the persisted setting only (`isOmoNativeTelemetryConfigEnabled`, neutral env, with the record's own config selection on top: each window and pending record carries the `OMO_PROFILE`/`OCX_PROFILE`/`OPENCODE_CONFIG_DIR`/`HOME`/`USERPROFILE` its writer consented under in `scope.config`, so a profile-consented record is judged by that profile, #11198; the send, resume and recovery gate (`isScopeConsented`) applies the same selection with this process's own env opt-outs; records without it, written before #11198, are judged as before): an env opt-out (`DO_NOT_TRACK`, `OMO_DISABLE_TELEMETRY`, CI, ...) belongs to its own process and deletes nothing another session queued. Under a persisted opt-out the entry purge also deletes unreadable or unknown-version summary files, which no process can send. Every registration, whatever the consent, first removes the pre-#10225 `<state>/rollups/` store once per process (`session-summary-legacy-rollups.ts`, node:fs only): files and links only, never deleting what a link points to (a `rollups` link is unlinked itself, and before every delete the path is re-checked by lstat to be the same directory, device and inode, so a swap for a link mid-pass stops the pass at the next delete; without unlinkat a swap between that check and the unlink can still take one same-named file), at most 1,000 entries per start, the emptied directory last, and a counts-only `telemetry_legacy_rollups_removed` diagnostic; deliberate skips (subdirectories, the cap, a stopped pass) are not reported as write failures. A mid-session opt-out drops its windows on the next write or the 5 s consent tick. `turn_end` and `session_shutdown` without a cwd keep the last known one. A record is sent or resumed only when its mark is `enabled` AND consent re-read for its scope is still on (`isRecordSendable`); anything else, including records older than the mark or scope, is deleted unsent. |
| `session-summary-publisher.ts` / `-recovery.ts` | Publication protocol (pending write -> seq advance -> publish -> delete on ack; an ack is a 2xx answer, any other answer, network error or timeout keeps the pending file, and only a permanent rejection (400/413) deletes it, counted in `dropped_events`) and the sweep run at first capture, on the first turn and on each 60 s heartbeat: re-send dead owners' pending events under the same uuid, recover windows whose owner is gone AND whose heartbeat is > 5 min old, expire > 14 day files into `dropped_events`. `-recovery.ts` is the entry of the `extensions/omo-telemetry-recovery.js` sidecar, which also carries everything else only a closed window or a sweep needs: the send step and the capture-state publish (`session-summary-delivery.ts`), continuing another process's window (`session-summary-adopt.ts`), the recovery-only storage operations (`session-summary-recovery-storage.ts`: leftovers, exclusive create, window and pending claims), the opt-out purge, owner liveness (`session-summary-liveness.ts`) and telemetry-core's immediate sender (the plugin manifest maps `#telemetry-immediate-send` to the same file). One memoized load per recorder (`memoizeLoad`, retried after a failure) serves them all: a sweep loads it only when the directory holds a leftover, a pending event not in flight, or a window this process did not write; a window close loads it to send; a session about to continue another process's window loads it for the liveness check and the claim. A normal turn loads nothing, and the own-process start stamp in the owner entry stays in the entry bundle. `CORRUPT` is a registered symbol so both bundles agree on it. Proven by spawned-process kill tests (`session-summary-process.test.ts`). |
| `session-summary-sampling.ts` / `session-role.ts` / `session-role-resolver.ts` | Raw-turn sampling (`resolveRawTurnSampleRate`: collector sink -> 1, else env > `telemetry.rawTurnSampleRate` > 0.05) and the closed `session_role` vocabulary (`root`/`child`/`unknown`). |
| `omo-native-parallel.ts` / `wave-assembler.ts` | Tool-execution concurrency observation. Waves are interval-graph connected components with `spanMs`, bounded by `MAX_TRACKED_CALLS` (2000). Timestamps are handler-entry stamps because Senpi tool events carry none. |
| `omo-native-eval.ts` / `eval-cell-correlation.ts` | Strictly parses Senpi `senpi.eval.execution` v1 payloads into fixed scalar rollups and correlates `cellId` to the outer eval/session without retaining names, args, paths, or previews. Detached correlation survives the outer end and clears on event or session teardown. |
| `omo-native-parallel-summary.ts` | `parallelism_summary`, exactly once per session at `session_shutdown`; owns its registration order versus the session client and registry teardown. |
| `eval-classifier.ts` / `savings-math.ts` | Wave bucketing (`eval_only`/`non_eval`/`mixed`, never folded together) and span-based savings math (modeled vs labeled upper bound, negatives not clamped). |
| `parallelism-schema.ts` | Privacy schema for `parallelism_summary`, including historical `parallelism_v1` and emitted `parallelism_v2` fixed fields. |
| `omo-native-kibitzer-summary.ts` | `kibitzer_summary`, exactly once per session at `session_shutdown`, from the memory component's wake/offer observer seam; same registration-order constraint as the parallelism summary. |
| `kibitzer-schema.ts` | Privacy schema for `kibitzer_summary`: counts, durations and a masked model id; nudge paths and hints never leave the memory component. |
| `event-schemas.ts` | `OMO_NATIVE_EVENT_SCHEMAS` and the derived per-event allowlists; `product-identity.ts` re-exports them. |
| `delegation-schema.ts` / `category-config-schema.ts` | Privacy schemas for `delegation_completed` and `category_config`, re-exported into `OMO_NATIVE_EVENT_SCHEMAS`. |
| `delegation-projection.ts` | Pure `TaskRecord` + terminal edge + steer counts -> `delegation_completed` property bag. Explicit scalar allowlist; every free-text record field is excluded by construction and pinned by an exact-key-set test. |
| `omo-native-delegation.ts` | Subscribes the task terminal-observer ledger, dedupes on `(task_seq, run_epoch)`, counts `steered`/`steer_queued` jsonl lines for the current epoch, and captures `delegation_completed`. Fire-and-forget, unsubscribes on dispose. |
| `omo-native-category-config.ts` | Captures a `category_config` snapshot per `config_generation`; a generation is spent only when the canonical exportable map changed. Custom category and model names never leave the machine. |
| `omo-native-crash.ts` / `process-crash-records.ts` / `crash-schema.ts` | `process_crashed`, sent from the `session_started` path (so after every telemetry gate) once per crash an earlier process recorded: reads every endpoint's `<agentDir>/rpc-host-daemon/*/crashes.jsonl` (fingerprint includes the endpoint dir, `shard_kind` `p`/`i`/`none`/`unknown` from the endpoint's `endpoint.json` or boot `settings.json` socket basename via senpi-task `parseShardBasename`, trusted only when the socket hashes to that dir; never the key or socket) and `<agentDir>/process-crashes/crashes.jsonl` (senpi#2194 markers plus senpi-task parent records), claims each record by exclusive-create under `<state>/crash-reports/<fingerprint>` before sending, skips malformed lines and records older than 14 days, caps 20 per start. Versions are version-shaped or `unknown`. |
| `model-vocabulary.ts` | `KNOWN_MODELS` / provider vocabulary so every shipped fallback-chain rung masks to itself, not `custom`, plus `ALL_KNOWN_MODEL_IDS` — the provider-independent flat set of exportable model ids. |
| `schema-doc.test.ts` | Pins the generated schema block in `docs/reference/senpi-telemetry.md` against `OMO_NATIVE_EVENT_SCHEMAS`. |
| `telemetry.test-support.ts` | Recorded transport factory, fixed clock, fake os provider for tests. |
## Event model
- Legacy product: single event `omo_senpi_daily_active`, reason `session_start`. Distinct id is `sha256` of the random `install-id` file in its state dir (telemetry-core `install-id.ts`); once-per-UTC-day dedupe lives in the same dir.
- `omo-native` product: `daily_active`, `session_started`, `prompt_submitted`, `turn_completed`, `skill_loaded`, `delegation_started`, `delegation_completed`, `category_config`, `feature_used`, `session_summary`, `computer_use_activation`, `computer_use_permission_denied`, `computer_use_engine_error`, `handoff_hint`, `parallelism_summary`, `kibitzer_summary`, `process_crashed`. Every event is schema-declared from `product-identity.ts`; properties outside the allowlist do not ship. Session ids are salted sha256 hashes, salt local to the machine. Distinct id is `install_hash` = sha256 of the random install-id file (schema v4). No identifier is derived from the hostname or any machine or user attribute. Common properties stamped on every event are published in `OMO_NATIVE_COMMON_PROPERTY_SCHEMAS`.
- `parallelism_summary` remains one event per session. V2 adds eval event-bus availability, accepted/rejected execution counts, nested status/duration totals, outer eval wrapper counts, and mixed-wave direct counts. Existing non-eval wave and savings formulas are unchanged.
- Free-form strings are masked to closed vocabularies: unknown providers/models/skills/agents become `custom` or are dropped. Numeric properties are bucketed where cardinality matters.
## Privacy and opt-out
- Env opt-outs (telemetry-core `env.ts`): `DO_NOT_TRACK`, `OMO_DISABLE_TELEMETRY` (`1|true|yes`), `OMO_DISABLE_POSTHOG` / `OMO_SENPI_DISABLE_POSTHOG` (`1|true|yes`), `OMO_SEND_ANONYMOUS_TELEMETRY` / `OMO_SENPI_SEND_ANONYMOUS_TELEMETRY` (`0|false|no|yes`). Config gate: `isOmoTelemetryEnabled` from `omo-config-core`.
- Never captured: prompt text, file paths, hostnames in clear, raw session ids, custom provider names, and custom model names (any model id outside the shipped vocabulary — fine-tunes, private deployment names, internal codenames).
- Provider and model are masked by DIFFERENT rules, and `maskProviderAndModel` is the single place that decides: `provider` is exported only when it is in `KNOWN_PROVIDERS` (a user-authored gateway name never leaves the machine), while `model_id` is exported whenever it matches `ALL_KNOWN_MODEL_IDS` regardless of the routing provider, because a shipped model id is a public product name. Changing this boundary means changing the published disclosure in `docs/reference/senpi-telemetry.md` in the same commit.
- API keys come from `POSTHOG_API_KEY` or the write-only defaults named in `product-identity.ts` / telemetry-core constants; do not duplicate key literals elsewhere.
- First-run disclosure notice is mandatory and marker-gated; it fires only when telemetry is actually enabled.
- `consent-gate.audit.test.ts` matches every disk, network, process and transport call site in this directory (recursively) against `consent-gate-reviewed.test-support.ts`, keyed by file, enclosing function, enclosing guards and call text; a new writer, a writer moved out of its gated branch, or a stale entry fails it. While consent is off a session hashes to the `opted-out` sentinel and the fan-in drops it on `$session_id` alone, because `$session_id` is the only declared property that carries a session hash (pinned by the same test). The sentinel is not the only stop. When consent is off at start, the session client is never created, so nothing reaches the fan-in at all. When consent is withdrawn mid-session, later events still carry the live session's real hash in `$session_id`, so the sentinel drop does not fire for them; they stop because the fan-in reads the consent gate before every event it forwards, the raw `turn_completed` send reads it again at send time (sampling is decided per session, long before a turn ends), and the summary recorder reads it before each write. Every event-emitting call site declares its route to the gate in the reviewed table (`gate`, `fan-in`, `session-client`, `callers`, `unregistered`, checked by `consent-gate-routes.test-support.ts`), and the audit checks that route against the source as it is now, so a send path whose own guard, fan-in or session client no longer reads the gate fails. `omo-native-component.test.ts` pins the behaviour: after a mid-session withdrawal, no event is sent and no message carries the sentinel, and every event type in `OMO_NATIVE_PROPERTY_ALLOWLISTS` is driven through the fan-in so a new type is covered without editing the test. An event that passed the gate still waits in the PostHog SDK queue (20 events or 10 s); the native transport sends through `createConsentGatedFetch`, which reads the gate fresh at send time, and the read that first sees the withdrawal drains that queue through the same gate, so nothing queued before an opt-out is sent after it (`omo-native-consent-flush.test.ts`, real SDK against a local endpoint). Versions 5.0.0-beta.6 through 5.1.34 lacked the per-event read and kept sending after a mid-session withdrawal (#10328); `docs/reference/senpi-telemetry.md` ("Known issue fixed in 5.1.35") carries the public disclosure and the per-install erasure offer (install id via private vulnerability reporting; machine-hash-only history only where paired with that install alone; a history shared by several machines (same hostname hash) can't be erased automatically and needs the private request), worded identically to the CHANGELOG entry and #10328, so keep all three in step.
## Delegation telemetry invariants
- **Observer ledger.** Terminal edges arrive through the shared ledger in `../task/terminal-observers.ts` (`globalThis` + `Symbol.for`, one instance across re-registration), fed by the status-edge store wrapper that watches every write path (`save`/`replace`/`mutate`/`transition`), so reconciliation-written `lost` records are observed too. Observer callbacks are individually try/caught; a throwing observer never affects the store result or task liveness. `createOmoNativeDelegationCapture` subscribes at register and must unsubscribe on dispose, otherwise each session switch stacks another observer writing into a dead client.
- **Session-hash ownership.** Delegation events hash `record.parent_session_id`, the session that owns the task, and pass that hash explicitly. Only the `session_start` path may set `state.sessionHash`; a resumed old task must never redirect the live session's events by mutating shared state.
- **Child-process gate.** Native telemetry never emits from senpi-task child or team-member processes: `components/task/index.ts` returns early on `isTeamMemberProcess()`, and in-process children load zero extensions (`child-loader.ts`). This is a test-pinned invariant; a child turn must never produce a parent delegation or turn event.
## Conventions
- `lazy-posthog.ts` supplies the default transport factory through telemetry-core's SDK-free `./injected` entry. `posthog-runtime.ts` alone imports the SDK-bearing transport entry and ships as `omo-telemetry-posthog.js`. Captures start the import without awaiting it and retain the newest 256 in order; overflow drops oldest and reports once at flush/shutdown. Unused transports do not load the SDK on exit. A factory shares one import; import failures report once with the caller's diagnostic source and back off for 60 seconds before another call can retry.
- Fire-and-forget everywhere: capture failures surface once through the diagnostics callback or debug log, never to the user, never as a thrown error.
- New events require a schema entry + allowlist in `product-identity.ts`, a doc regeneration (`schema-doc.test.ts` fails otherwise), and bucketing for any unbounded value.
- Tests inject `env`, `now`, `osProvider`, `stateDir`, and `transportFactory`; no test touches the network or the real agent home.
## Anti-patterns
- Do not emit per-turn variants of session-scoped events (`parallelism_summary` and `kibitzer_summary` are once per session by design).
- Do not add a nudge path, hint or memory excerpt to `kibitzer_summary`; the observer seam carries counts only so that stays impossible by construction.
- Do not fold `mixed` waves into `non_eval` or clamp negative savings; both hide measurement anomalies.
- Do not add eval-internal calls to top-level waves or infer nested concurrency/savings from aggregate duration sums; the producer event does not carry the required interval graph.
- Do not add properties outside the declared schema, log raw session ids, or widen the timeout so telemetry can delay `session_start`.