spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

telemetry: complete rows gathered through $, sent in batches, serving built-in plugins onlyBrowse files
poteat authored on Sep 20, 2026 parent bf7d404 · commit 8439b426ee32
226 changed files+3312 -308compared with bf7d404
Mmods/README.md
Mmods/telemetry/.claude-plugin/plugin.json
Mmods/telemetry/README.md
Amods/telemetry/hooks/batching/batch-rows/batch-rows.ts
Amods/telemetry/hooks/batching/batch-rows/index.ts
Amods/telemetry/hooks/batching/batch-window-ms/batch-window-ms.ts
Amods/telemetry/hooks/batching/batch-window-ms/index.ts
Amods/telemetry/hooks/batching/index.ts
Amods/telemetry/hooks/batching/is-retriable/index.ts
Amods/telemetry/hooks/batching/is-retriable/is-retriable.ts
Amods/telemetry/hooks/batching/is-retriable/status-request-timeout/index.ts
Amods/telemetry/hooks/batching/is-retriable/status-request-timeout/status-request-timeout.ts
Amods/telemetry/hooks/batching/is-retriable/status-server-error/index.ts
Amods/telemetry/hooks/batching/is-retriable/status-server-error/status-server-error.ts
Amods/telemetry/hooks/batching/is-retriable/status-too-many-requests/index.ts
Amods/telemetry/hooks/batching/is-retriable/status-too-many-requests/status-too-many-requests.ts
Amods/telemetry/hooks/batching/message-of/index.ts
Amods/telemetry/hooks/batching/message-of/message-of.ts
Amods/telemetry/hooks/batching/pending-row/index.ts
Amods/telemetry/hooks/batching/pending-row/pending-row.ts
Amods/telemetry/hooks/batching/retry-delay-ms/index.ts
Amods/telemetry/hooks/batching/retry-delay-ms/retry-delay-ms.ts
Amods/telemetry/hooks/config-location/config-location.ts
Amods/telemetry/hooks/config-location/index.ts
Amods/telemetry/hooks/context/client-type-of/client-type-by-entrypoint/client-type-by-entrypoint.ts
Amods/telemetry/hooks/context/client-type-of/client-type-by-entrypoint/index.ts
Amods/telemetry/hooks/context/client-type-of/client-type-of.ts
Amods/telemetry/hooks/context/client-type-of/index.ts
Amods/telemetry/hooks/context/context/context.ts
Amods/telemetry/hooks/context/context/index.ts
Amods/telemetry/hooks/context/context-of.ts
Amods/telemetry/hooks/context/entrypoint-of/entrypoint-of.ts
Amods/telemetry/hooks/context/entrypoint-of/index.ts
Amods/telemetry/hooks/context/environment-fields/environment-fields.ts
Amods/telemetry/hooks/context/environment-fields/github-actions-fields/github-actions-fields.ts
Amods/telemetry/hooks/context/environment-fields/github-actions-fields/index.ts
Amods/telemetry/hooks/context/environment-fields/index.ts
Amods/telemetry/hooks/context/environment-fields-of/action-ref-of/action-ref-of.ts
Amods/telemetry/hooks/context/environment-fields-of/action-ref-of/index.ts
Amods/telemetry/hooks/context/environment-fields-of/environment-fields-of.ts
Amods/telemetry/hooks/context/environment-fields-of/github-actions-fields-of/github-actions-fields-of.ts
Amods/telemetry/hooks/context/environment-fields-of/github-actions-fields-of/index.ts
Amods/telemetry/hooks/context/environment-fields-of/index.ts
Amods/telemetry/hooks/context/environment-fields-of/tags-of/index.ts
Amods/telemetry/hooks/context/environment-fields-of/tags-of/tags-of.ts
Amods/telemetry/hooks/context/identity/global-config-path/global-config-path.ts
Amods/telemetry/hooks/context/identity/global-config-path/index.ts
Amods/telemetry/hooks/context/identity/identity/identity.ts
Amods/telemetry/hooks/context/identity/identity/index.ts
Amods/telemetry/hooks/context/identity/identity-of.ts
Amods/telemetry/hooks/context/identity/index.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/index.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/plausible-uuid.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/uuid-length-floor/index.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/uuid-length-floor/uuid-length-floor.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/uuid-length-limit/index.ts
Amods/telemetry/hooks/context/identity/plausible-uuid/uuid-length-limit/uuid-length-limit.ts
Amods/telemetry/hooks/context/index.ts
Amods/telemetry/hooks/context/linux-distro-of/index.ts
Amods/telemetry/hooks/context/linux-distro-of/linux-distro/index.ts
Amods/telemetry/hooks/context/linux-distro-of/linux-distro/linux-distro.ts
Amods/telemetry/hooks/context/linux-distro-of/linux-distro-of.ts
Amods/telemetry/hooks/context/linux-distro-of/os-release-value/index.ts
Amods/telemetry/hooks/context/linux-distro-of/os-release-value/os-release-value.ts
Amods/telemetry/hooks/context/machine/arch-of/arch-of.ts
Amods/telemetry/hooks/context/machine/arch-of/index.ts
Amods/telemetry/hooks/context/machine/index.ts
Amods/telemetry/hooks/context/machine/machine/index.ts
Amods/telemetry/hooks/context/machine/machine/machine.ts
Amods/telemetry/hooks/context/machine/machine-of.ts
Amods/telemetry/hooks/context/machine/platform-of/index.ts
Amods/telemetry/hooks/context/machine/platform-of/platform-of.ts
Amods/telemetry/hooks/context/machine/probe-script/index.ts
Amods/telemetry/hooks/context/machine/probe-script/probe-script.ts
Amods/telemetry/hooks/context/machine/probe-timeout-ms/index.ts
Amods/telemetry/hooks/context/machine/probe-timeout-ms/probe-timeout-ms.ts
Amods/telemetry/hooks/context/probe/index.ts
Amods/telemetry/hooks/context/probe/probe.ts
Amods/telemetry/hooks/context/probe-of/index.ts
Amods/telemetry/hooks/context/probe-of/linux-files/index.ts
Amods/telemetry/hooks/context/probe-of/linux-files/linux-files.ts
Amods/telemetry/hooks/context/probe-of/linux-files-of/index.ts
Amods/telemetry/hooks/context/probe-of/linux-files-of/linux-files-of.ts
Amods/telemetry/hooks/context/probe-of/probe-of.ts
Amods/telemetry/hooks/context/remote-hash-of/hex-of/hex-of.ts
Amods/telemetry/hooks/context/remote-hash-of/hex-of/hex-radix/hex-radix.ts
Amods/telemetry/hooks/context/remote-hash-of/hex-of/hex-radix/index.ts
Amods/telemetry/hooks/context/remote-hash-of/hex-of/index.ts
Amods/telemetry/hooks/context/remote-hash-of/index.ts
Amods/telemetry/hooks/context/remote-hash-of/normalized-remote-of/index.ts
Amods/telemetry/hooks/context/remote-hash-of/normalized-remote-of/normalized-remote-of.ts
Amods/telemetry/hooks/context/remote-hash-of/remote-hash-length/index.ts
Amods/telemetry/hooks/context/remote-hash-of/remote-hash-length/remote-hash-length.ts
Amods/telemetry/hooks/context/remote-hash-of/remote-hash-of.ts
Amods/telemetry/hooks/context/session-fields/index.ts
Amods/telemetry/hooks/context/session-fields/session-fields.ts
Amods/telemetry/hooks/context/session-fields-of/index.ts
Amods/telemetry/hooks/context/session-fields-of/session-fields-of.ts
Amods/telemetry/hooks/context/shell-of/index.ts
Amods/telemetry/hooks/context/shell-of/known-shells/index.ts
Amods/telemetry/hooks/context/shell-of/known-shells/known-shells.ts
Amods/telemetry/hooks/context/shell-of/shell-of.ts
Amods/telemetry/hooks/context/terminal-of/index.ts
Amods/telemetry/hooks/context/terminal-of/jetbrains-ides/index.ts
Amods/telemetry/hooks/context/terminal-of/jetbrains-ides/jetbrains-ides.ts
Amods/telemetry/hooks/context/terminal-of/terminal-of.ts
Amods/telemetry/hooks/context/vcs-of/distinct/distinct.ts
Amods/telemetry/hooks/context/vcs-of/distinct/index.ts
Amods/telemetry/hooks/context/vcs-of/index.ts
Amods/telemetry/hooks/context/vcs-of/vcs-markers/index.ts
Amods/telemetry/hooks/context/vcs-of/vcs-markers/vcs-markers.ts
Amods/telemetry/hooks/context/vcs-of/vcs-of.ts
Amods/telemetry/hooks/context/version-of/index.ts
Amods/telemetry/hooks/context/version-of/version-of.ts
Amods/telemetry/hooks/context/wsl-version-of/index.ts
Amods/telemetry/hooks/context/wsl-version-of/wsl-version-of.ts
Amods/telemetry/hooks/deployment/deployment-name/deployment-name.ts
Amods/telemetry/hooks/deployment/deployment-name/index.ts
Amods/telemetry/hooks/deployment/deployment-of.ts
Amods/telemetry/hooks/deployment/deployment-signal/deployment-signal.ts
Amods/telemetry/hooks/deployment/deployment-signal/index.ts
Amods/telemetry/hooks/deployment/deployments/deployments.ts
Amods/telemetry/hooks/deployment/deployments/index.ts
Amods/telemetry/hooks/deployment/index.ts
Mmods/telemetry/hooks/entries/batch-of.ts
Amods/telemetry/hooks/entries/batch-stamp/batch-stamp.ts
Amods/telemetry/hooks/entries/batch-stamp/index.ts
Amods/telemetry/hooks/entries/event-data-of/auth-of/auth-of.ts
Amods/telemetry/hooks/entries/event-data-of/auth-of/index.ts
Amods/telemetry/hooks/entries/event-data-of/event-data-of.ts
Amods/telemetry/hooks/entries/event-data-of/index.ts
Amods/telemetry/hooks/entries/event-data-of/metadata-of/index.ts
Amods/telemetry/hooks/entries/event-data-of/metadata-of/metadata-of.ts
Mmods/telemetry/hooks/entries/index.ts
Dmods/telemetry/hooks/entries/row-session/index.ts
Dmods/telemetry/hooks/entries/row-session/row-session.ts
Amods/telemetry/hooks/entries/wire-of/index.ts
Amods/telemetry/hooks/entries/wire-of/wire-of.ts
Mmods/telemetry/hooks/environment/environment.ts
Amods/telemetry/hooks/facts/facts.ts
Amods/telemetry/hooks/facts/index.ts
Amods/telemetry/hooks/gate/caught/caught.ts
Amods/telemetry/hooks/gate/caught/index.ts
Amods/telemetry/hooks/gate/gate-next/gate-next.ts
Amods/telemetry/hooks/gate/gate-next/index.ts
Amods/telemetry/hooks/gate/index.ts
Amods/telemetry/hooks/gate/refused/index.ts
Amods/telemetry/hooks/gate/refused/refused.ts
Amods/telemetry/hooks/gate/served/index.ts
Amods/telemetry/hooks/gate/served/served.ts
Amods/telemetry/hooks/gate/served-tiers/index.ts
Amods/telemetry/hooks/gate/served-tiers/served-tiers.ts
Mmods/telemetry/hooks/hooks.json
Mmods/telemetry/hooks/index.ts
Mmods/telemetry/hooks/is-analytics-off/is-analytics-off.ts
Amods/telemetry/hooks/policy-pins/index.ts
Amods/telemetry/hooks/policy-pins/policy-pins.ts
Mmods/telemetry/hooks/register.ts
Amods/telemetry/hooks/sender/index.ts
Amods/telemetry/hooks/sender/sender.ts
Mmods/telemetry/hooks/telemetry-deps/telemetry-deps.ts
Mmods/telemetry/hooks/telemetry-of/telemetry-of.ts
Dmods/telemetry/tests/fixtures/analytics-off-environments.ts
Dmods/telemetry/tests/fixtures/base64-alphabet.ts
Dmods/telemetry/tests/fixtures/base64-decoded.ts
Dmods/telemetry/tests/fixtures/base64-digit-bits.ts
Dmods/telemetry/tests/fixtures/batch-of.ts
Amods/telemetry/tests/fixtures/config-path.ts
Amods/telemetry/tests/fixtures/deaf-telemetry.ts
Amods/telemetry/tests/fixtures/device-id.ts
Amods/telemetry/tests/fixtures/ended.ts
Amods/telemetry/tests/fixtures/environments/analytics-off-environments.ts
Amods/telemetry/tests/fixtures/environments/github-action-env.ts
Amods/telemetry/tests/fixtures/environments/host-managed-bedrock-env.ts
Amods/telemetry/tests/fixtures/environments/index.ts
Amods/telemetry/tests/fixtures/environments/linux-files.ts
Amods/telemetry/tests/fixtures/environments/linux-probed.ts
Amods/telemetry/tests/fixtures/environments/sending-env.ts
Amods/telemetry/tests/fixtures/environments/windows-env.ts
Amods/telemetry/tests/fixtures/expected-row.ts
Mmods/telemetry/tests/fixtures/first-party-session.ts
Amods/telemetry/tests/fixtures/gate/failing-next.ts
Amods/telemetry/tests/fixtures/gate/index.ts
Amods/telemetry/tests/fixtures/gate/next-of.ts
Amods/telemetry/tests/fixtures/global-config.ts
Amods/telemetry/tests/fixtures/held.ts
Mmods/telemetry/tests/fixtures/index.ts
Dmods/telemetry/tests/fixtures/ingest-batch.ts
Dmods/telemetry/tests/fixtures/ingest-event.ts
Amods/telemetry/tests/fixtures/listing.ts
Dmods/telemetry/tests/fixtures/marking.ts
Amods/telemetry/tests/fixtures/plugins/holding.ts
Amods/telemetry/tests/fixtures/plugins/index.ts
Amods/telemetry/tests/fixtures/plugins/managing.ts
Amods/telemetry/tests/fixtures/plugins/marking.ts
Amods/telemetry/tests/fixtures/plugins/meddling.ts
Amods/telemetry/tests/fixtures/plugins/reaching.ts
Amods/telemetry/tests/fixtures/plugins/recording.ts
Amods/telemetry/tests/fixtures/plugins/replacing.ts
Amods/telemetry/tests/fixtures/plugins/swallowing.ts
Amods/telemetry/tests/fixtures/plugins/visiting.ts
Amods/telemetry/tests/fixtures/policy-option.ts
Amods/telemetry/tests/fixtures/probed.ts
Amods/telemetry/tests/fixtures/read-tool.ts
Dmods/telemetry/tests/fixtures/recording.ts
Mmods/telemetry/tests/fixtures/refused.ts
Amods/telemetry/tests/fixtures/rejected.ts
Amods/telemetry/tests/fixtures/remote-hash.ts
Amods/telemetry/tests/fixtures/repo.ts
Dmods/telemetry/tests/fixtures/row-of.ts
Amods/telemetry/tests/fixtures/sent-session.ts
Amods/telemetry/tests/fixtures/session-options.ts
Amods/telemetry/tests/fixtures/started.ts
Amods/telemetry/tests/fixtures/typed.ts
Amods/telemetry/tests/fixtures/wire/base64-alphabet.ts
Amods/telemetry/tests/fixtures/wire/base64-decoded.ts
Amods/telemetry/tests/fixtures/wire/base64-digit-bits.ts
Amods/telemetry/tests/fixtures/wire/batch-of.ts
Amods/telemetry/tests/fixtures/wire/event-name-of.ts
Amods/telemetry/tests/fixtures/wire/index.ts
Amods/telemetry/tests/fixtures/wire/ingest-batch.ts
Amods/telemetry/tests/fixtures/wire/ingest-event.ts
Amods/telemetry/tests/fixtures/wire/rows-of.ts
Amods/telemetry/tests/gate.test.ts
Mmods/telemetry/tests/register.test.ts
Mmods/telemetry/types/index.d.ts
@@ -9,7 +9,7 @@
99 | --- | --- | --- |
1010 | [`sec-default`](sec-default) | Keeps an organization's classic hooks, prompt content, managed settings and tool policy out of reach of the plugins a person installs; adds no policy of its own. | Outermost, on a machine with managed settings or for a Team or Enterprise organization, unless managed `prependPlugins` says otherwise |
1111 | [`diff`](diff) | `/diff`: the session's uncommitted changes in a pane beside the transcript, file by file with their hunks, refreshed as Claude edits files and runs commands. | Built in |
12-| [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a plugin can record an event as a first-party analytics row; sends nothing wherever Claude Code's analytics are off. | Built in |
12+| [`telemetry`](telemetry) | Adds `$.telemetry` (`log`, `mark`) in the `engine.create` fold so a built-in plugin can record an event as a first-party analytics row, sent in batches; refuses installed plugins; sends nothing wherever Claude Code's analytics are off. | Built in |
1313 | [`agents-md`](agents-md) | `AGENTS.md` as project instructions, by one option: loaded where the project has no `CLAUDE.md` of its own (`claude-md-or-agents-md`, the default) or beside it (`claude-md-and-agents-md`), placed and framed exactly as the engine places `CLAUDE.md`, nested ones on a `Read`; or the project's and the person's instruction files dropped and the organization's kept (`managed-only`); or `CLAUDE.md` alone, as the engine reads it (`claude-md`). | Built in |
1414
1515 Each folder is a complete plugin: `.claude-plugin/plugin.json`, a
@@ -1,7 +1,7 @@
11 {
22 "name": "telemetry",
33 "version": "0.1.0",
4- "description": "Plugin analytics: adds $.telemetry in the engine.create fold, so a plugin logs an event or marks a feature's use as one first-party row per call, sent with the session's own credential.",
4+ "description": "Plugin analytics: adds $.telemetry in the engine.create fold, so a plugin logs an event or marks a feature's use as a first-party row, sent in batches with the session's own credential.",
55 "author": {
66 "name": "Anthropic"
77 },
@@ -1,32 +1,55 @@
11 # telemetry
22
33 Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry` to
4-the engine interface every plugin above it is handed, built over the
5-`$.session` and `$.http` nouns beneath. `$.telemetry.log({ event, props })`
6-sends one event as one first-party row, `tengu_plugin_<event>`;
7-`$.telemetry.mark({ feature, kind, reason?, props? })` marks one use of a
8-feature as the CLI's own feature events do, `tengu_feature_<kind>` with a
9-`feature_name` and the mark's properties beside it. Each call is one POST to
10-the event-logging ingest with the session's own credential
11-(`$.session.authorize()`, resolved at each call), one attempt, nothing
12-batched; a session with no first-party credential, or an ingest that
13-refuses, rejects the caller's promise.
4+the engine interface every plugin above it is handed, built over the nouns
5+beneath, and a hook on its own two events serves the plugins built into
6+Claude Code alone: a call from a plugin a person installed or an
7+administrator listed is refused with a reason (the host stamps every call
8+with the plugin that raised it, `next.origin`, and the gate reads its tier).
9+`$.telemetry.log({ event, props })` queues one event as one first-party
10+row, `tengu_plugin_<event>`; `$.telemetry.mark({ feature, kind, reason?,
11+props? })` marks one use of a feature as the CLI's own feature events do,
12+`tengu_feature_<kind>` with a `feature_name` and the mark's properties
13+beside it. Both resolve once the row is queued. Rows go out in batches: one
14+POST to the event-logging ingest with the session's own credential
15+(`$.session.authorize()`, resolved for each batch) a few seconds after the
16+first row was queued, at once when a hundred wait, and when the session
17+ends; a batch the ingest refuses with a server error, a timeout or a rate
18+limit is tried once more. A session with no first-party credential, or an
19+ingest that still refuses, drops the batch; each outcome is one line in the
20+debug log.
1421
22+Each row carries what the CLI's own rows carry, gathered through `$` once
23+a session: an event id, the install's device id and the signed-in account's
24+ids from the CLI's global config, the session's id, model, client type,
25+entrypoint and interactivity, and an `env` block (platform and
26+architecture from one `uname` probe, terminal, shell, package managers and
27+runtimes, CI and GitHub Actions, the remote container, the deployment, the
28+Linux distribution and kernel, WSL, the working directory's version
29+control), with the repository's remote hash beside the row's properties.
30+What the engine alone knows (its version and build time, its runtime's
31+version, the process's memory, the request's betas, the subscription tier,
32+the calling agent) is not on `$`, and those columns stay empty.
33+
1534 It sends nothing wherever the CLI's own analytics are off: under
1635 `DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
17-`DO_NOT_TRACK`, on any third-party provider (Bedrock, Vertex, Foundry and
18-kin), and on a deployment with its own OAuth URL. Each is read through
19-`$.env` at every call, rows go one after another, and the credential is
20-authorized afresh right before each POST, so a session that has since moved
21-to a third-party provider or a cloud gateway sends nothing more. The row's
22-`user_type` is `ant` when `USER_TYPE` says so, else `external`.
36+`DO_NOT_TRACK`, in a test run, on any third-party provider (Bedrock,
37+Vertex, Foundry and kin) the host does not manage, on a cloud gateway
38+(the environment's switch or the managed policy's login pins), and on a
39+deployment with its own OAuth URL. Each is read through `$.env` and
40+`$.settings` before every batch, so a session that has since moved to a
41+third-party provider or a gateway sends nothing more; when the switches
42+cannot be read, nothing is sent either. The row's `user_type` is `ant`
43+when `USER_TYPE` says so, else `external`.
2344
2445 Nothing free-form reaches a row. An event name and every property key is a
2546 snake_case token; a value is a finite number, a boolean, or a Choice (a
2647 string named together with the list it is chosen from), under `log` and
2748 `mark` alike; `mark` takes `ok`, `sad` or `bad`, with a `reason` required on
2849 the last two and refused on the first. An entry that breaks a rule is
29-refused before anything is sent.
50+refused before anything is queued. Of the environment, a variable whose
51+value is a secret, or names a person or a host, is read for whether it is
52+set and nothing more; a shell is its basename from a closed list.
3053
3154 `hooks/register.ts` is the module; `types/index.d.ts` is the noun's contract,
3255 the one declaration of `$.telemetry` that this mod's hooks, a mod calling the
@@ -35,20 +58,26 @@
3558 ## What it hooks
3659
3760 `engine.create`: `{ ...await next(e), telemetry }`, so the noun is added and
38-nothing beneath is replaced.
61+nothing beneath is replaced. `telemetry.*`, the gate: a caller in the
62+built-in tier (or the engine) goes on, any other is refused, and a gate
63+that throws refuses too. `session.start`, to learn whether a person is at
64+the prompt; `session.end`, to send what still waits.
3965
4066 ## What it calls on `$`
4167
42-`session.authorize`, `session.id`, `session.model`, `http.fetch`, `env.get`
43-(the switches above and `USER_TYPE`, by literal name), each on the
44-interface the fold handed it.
68+`session.authorize`, `session.id`, `session.model`, `session.surfaces`,
69+`session.cwd`, `session.repo`, `settings.read`, `env.get` (the switches and
70+the describing variables, by literal name), `fs.read`, `fs.list`,
71+`fs.exists`, `process.run` (one `sh -c` of `uname` and `command -v`),
72+`clock.after`, `clock.sleep`, `http.fetch` and `ui.log` (to the debug log),
73+each on the interface the fold handed it.
4574
46-## Where it runs
75+## Where it runs, whom it serves
4776
48-This plugin is seated by the CLI itself, on internal builds whose own
49-analytics are on, and nowhere else: `session.authorize` exists only there,
50-and the rows it writes join tables only the CLI's own events reach. It is
51-not meant to be installed or loaded with `--plugin-dir`; the folder has a
52-manifest so it reads like every other plugin, not so it can stand alone. A
53-plugin that calls `$.telemetry` where this one is absent finds no such noun
54-and should treat that as "no analytics here".
77+This plugin is seated by the CLI itself, on every build whose own analytics
78+are on, and nowhere else; it serves the plugins bundled with the CLI and
79+refuses every other caller. It is not meant to be installed or loaded with
80+`--plugin-dir`; the folder has a manifest so it reads like every other
81+plugin, not so it can stand alone. A built-in that calls `$.telemetry`
82+where this one is absent finds no such noun and should treat that as "no
83+analytics here".
@@ -1,0 +1,5 @@
1+/**
2+ * The most rows one batch carries: a queue this full goes out at once
3+ * rather than on the timer.
4+ */
5+export const BATCH_ROWS = 100
@@ -1,0 +1,3 @@
1+export * from './batch-rows.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How long a queued row waits for company before its batch goes out, in
3+ * milliseconds; a session that ends sooner sends what waits then.
4+ */
5+export const BATCH_WINDOW_MS = 5000
@@ -1,0 +1,3 @@
1+export * from './batch-window-ms.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+export * from './batch-rows'
2+export * from './batch-window-ms'
3+export * from './is-retriable'
4+export * from './message-of'
5+export * from './pending-row'
6+export * from './retry-delay-ms'
7+
8+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './is-retriable.js'
2+export * from './status-request-timeout'
3+export * from './status-server-error'
4+export * from './status-too-many-requests'
5+
6+export * as default from '.'
@@ -1,0 +1,15 @@
1+import { STATUS_REQUEST_TIMEOUT } from './status-request-timeout'
2+import { STATUS_SERVER_ERROR } from './status-server-error'
3+import { STATUS_TOO_MANY_REQUESTS } from './status-too-many-requests'
4+
5+/**
6+ * Whether an answer from the ingest is worth the one retry: a server error,
7+ * a timeout or a rate limit; anything else it refused stays refused.
8+ *
9+ * @param status the HTTP status the ingest answered
10+ * @returns true for 5xx, 408 and 429
11+ */
12+export const isRetriable = (status: number) =>
13+ status >= STATUS_SERVER_ERROR ||
14+ status === STATUS_REQUEST_TIMEOUT ||
15+ status === STATUS_TOO_MANY_REQUESTS
@@ -1,0 +1,3 @@
1+export * from './status-request-timeout.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * HTTP 408: the ingest gave up waiting for the request; worth the one retry.
3+ */
4+export const STATUS_REQUEST_TIMEOUT = 408
@@ -1,0 +1,3 @@
1+export * from './status-server-error.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * HTTP 500, the first server-side status: it and everything above is the
3+ * ingest's own failure and worth the one retry.
4+ */
5+export const STATUS_SERVER_ERROR = 500
@@ -1,0 +1,3 @@
1+export * from './status-too-many-requests.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+/**
2+ * HTTP 429: the ingest is shedding load; worth the one retry after a pause.
3+ */
4+export const STATUS_TOO_MANY_REQUESTS = 429
@@ -1,0 +1,3 @@
1+export * from './message-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,9 @@
1+/**
2+ * What a batch's failure says, for the refusal each of its rows rejects
3+ * with: the error's own message, or the value as text.
4+ *
5+ * @param error what sending the batch threw
6+ * @returns the message
7+ */
8+export const messageOf = (error: unknown) =>
9+ error instanceof Error ? error.message : String(error)
@@ -1,0 +1,3 @@
1+export type * from './pending-row.js'
2+
3+export * as default from '.'
@@ -1,0 +1,11 @@
1+import type Entries from '../../entries'
2+
3+/**
4+ * One row waiting for its batch: the checked fields, and the id and time
5+ * it was logged with.
6+ */
7+export type PendingRow = {
8+ fields: Entries.Fields
9+ eventId: string
10+ loggedAt: string
11+}
@@ -1,0 +1,3 @@
1+export * from './retry-delay-ms.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How long the one retry waits after a POST that failed or was answered with
3+ * a status worth retrying, in milliseconds.
4+ */
5+export const RETRY_DELAY_MS = 500
@@ -1,0 +1,12 @@
1+/**
2+ * Where the CLI's global config file is, read off the environment; read to
3+ * find the file, never sent.
4+ *
5+ * The config directory when one is named, else the home directory (the
6+ * profile directory on Windows).
7+ */
8+export type ConfigLocation = {
9+ readonly configDir: string | undefined
10+ readonly home: string | undefined
11+ readonly userProfile: string | undefined
12+}
@@ -1,0 +1,3 @@
1+export type * from './config-location.js'
2+
3+export * as default from '.'
@@ -1,0 +1,12 @@
1+/**
2+ * The client types the CLI names by entrypoint alone: the SDKs, the VS Code
3+ * and desktop hosts, and the local agent.
4+ */
5+export const CLIENT_TYPE_BY_ENTRYPOINT: Readonly<Record<string, string>> = {
6+ 'sdk-ts': 'sdk-typescript',
7+ 'sdk-py': 'sdk-python',
8+ 'sdk-cli': 'sdk-cli',
9+ 'claude-vscode': 'claude-vscode',
10+ 'local-agent': 'local-agent',
11+ 'claude-desktop': 'claude-desktop',
12+}
@@ -1,0 +1,3 @@
1+export * from './client-type-by-entrypoint.js'
2+
3+export * as default from '.'
@@ -1,0 +1,26 @@
1+import type { Facts } from '../../facts'
2+import IsAnalyticsOff from '../../is-analytics-off'
3+import { CLIENT_TYPE_BY_ENTRYPOINT } from './client-type-by-entrypoint'
4+
5+/**
6+ * The row's `client_type` as the CLI decides it at start: a GitHub Action,
7+ * an SDK or host by entrypoint, `remote` with an ingress token, else `cli`.
8+ *
9+ * @param facts the variables as read once for the session
10+ * @returns the client type
11+ */
12+export function clientTypeOf(facts: Facts) {
13+ const isRemote =
14+ facts.entrypoint === 'remote' ||
15+ facts.hasSessionAccessToken ||
16+ facts.hasSessionIngressTokenFile ||
17+ facts.hasWebsocketAuthFileDescriptor ||
18+ IsAnalyticsOff.isEnvTruthy(facts.claudeCodeRemote)
19+
20+ const isGithubAction = IsAnalyticsOff.isEnvTruthy(facts.githubActions)
21+
22+ return isGithubAction
23+ ? 'github-action'
24+ : (CLIENT_TYPE_BY_ENTRYPOINT[facts.entrypoint ?? ''] ??
25+ (isRemote ? 'remote' : 'cli'))
26+}
@@ -1,0 +1,4 @@
1+export * from './client-type-by-entrypoint'
2+export * from './client-type-of.js'
3+
4+export * as default from '.'
@@ -1,0 +1,17 @@
1+import type { EnvironmentFields } from '../environment-fields'
2+import type { Identity } from '../identity'
3+import type { SessionFields } from '../session-fields'
4+
5+/**
6+ * Everything a row carries that holds for the whole session, gathered once
7+ * before the first batch.
8+ *
9+ * Who it is from, the session's constant fields, its `env` block, and the
10+ * repository's remote hash for the metadata.
11+ */
12+export type Context = {
13+ readonly identity: Identity
14+ readonly session: SessionFields
15+ readonly environment: EnvironmentFields
16+ readonly remoteHash: string | undefined
17+}
@@ -1,0 +1,3 @@
1+export type * from './context.js'
2+
3+export * as default from '.'
@@ -1,0 +1,30 @@
1+import type { TelemetryDeps } from '../telemetry-deps'
2+import type { Context } from './context'
3+import { environmentFieldsOf } from './environment-fields-of'
4+import { probeOf } from './probe-of'
5+import { sessionFieldsOf } from './session-fields-of'
6+
7+/**
8+ * Gathers and shapes what holds for the whole session, once: who the rows
9+ * are from, the session's fields, its `env` block and the remote hash.
10+ *
11+ * Nothing in it is a secret or a path: every field is the closed value the
12+ * CLI's own column holds.
13+ *
14+ * @param deps the calls on the nouns beneath
15+ * @param isInteractive whether a person is at the prompt
16+ * @returns the session's context for every batch after
17+ */
18+export async function contextOf(
19+ deps: TelemetryDeps,
20+ isInteractive: boolean,
21+): Promise<Context> {
22+ const probe = await probeOf(deps, isInteractive)
23+
24+ return {
25+ identity: probe.identity,
26+ session: sessionFieldsOf(probe.facts),
27+ environment: environmentFieldsOf(probe),
28+ remoteHash: probe.remoteHash,
29+ }
30+}
@@ -1,0 +1,13 @@
1+/**
2+ * The row's `entrypoint` field: the variable as the CLI or its host set it
3+ * when spelled like one (a short lowercase token), else `other`.
4+ *
5+ * @param variable CLAUDE_CODE_ENTRYPOINT as read; undefined stays so
6+ * @returns the field, or undefined
7+ */
8+export function entrypointOf(variable: string | undefined) {
9+ const isSpelled =
10+ variable === undefined || /^[a-z][a-z0-9_-]{0,39}$/.test(variable)
11+
12+ return isSpelled ? variable : 'other'
13+}
@@ -1,0 +1,3 @@
1+export * from './entrypoint-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,41 @@
1+import type { GithubActionsFields } from './github-actions-fields'
2+
3+/**
4+ * The `env` block every row of the session carries, as the CLI's own rows
5+ * carry it, before its keys are spelled for the wire (Entries.wireOf).
6+ *
7+ * The machine, the terminal and shell, CI and GitHub Actions, the remote
8+ * container, the deployment. `isClaudeAiAuth` follows the credential each
9+ * batch is sent with and joins these then; an absent field is undefined.
10+ */
11+export type EnvironmentFields = {
12+ readonly platform: string
13+ readonly platformRaw: string
14+ readonly arch: string
15+ readonly terminal: string
16+ readonly shell: string
17+ readonly packageManagers: string
18+ readonly runtimes: string
19+ readonly isCi: boolean
20+ readonly isClaubbit: boolean
21+ readonly isGithubAction: boolean
22+ readonly isClaudeCodeAction: boolean
23+ readonly isClaudeCodeRemote: boolean
24+ readonly isLocalAgentMode: boolean
25+ readonly isConductor: boolean
26+ readonly deploymentEnvironment: string
27+ readonly remoteEnvironmentType: string | undefined
28+ readonly claudeCodeContainerId: string | undefined
29+ readonly claudeCodeRemoteSessionId: string | undefined
30+ readonly tags: readonly string[] | undefined
31+ readonly githubEventName: string | undefined
32+ readonly githubActionsRunnerEnvironment: string | undefined
33+ readonly githubActionsRunnerOs: string | undefined
34+ readonly githubActionRef: string | undefined
35+ readonly githubActionsMetadata: GithubActionsFields | undefined
36+ readonly wslVersion: string | undefined
37+ readonly linuxDistroId: string | undefined
38+ readonly linuxDistroVersion: string | undefined
39+ readonly linuxKernel: string | undefined
40+ readonly vcs: string | undefined
41+}
@@ -1,0 +1,9 @@
1+/**
2+ * The GitHub Actions ids a row's `env` block carries when the session runs
3+ * in a workflow: the actor, the repository and its owner, by number.
4+ */
5+export type GithubActionsFields = {
6+ readonly actorId: string | undefined
7+ readonly repositoryId: string | undefined
8+ readonly repositoryOwnerId: string | undefined
9+}
@@ -1,0 +1,3 @@
1+export type * from './github-actions-fields.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+export type * from './environment-fields.js'
2+export * from './github-actions-fields'
3+
4+export * as default from '.'
@@ -1,0 +1,11 @@
1+/**
2+ * The row's `github_action_ref`: what follows `claude-code-action/` in the
3+ * action's path, as the CLI reads it; undefined when the path names none.
4+ *
5+ * @param actionPath GITHUB_ACTION_PATH as read
6+ * @returns the ref, or undefined
7+ */
8+export const actionRefOf = (actionPath: string | undefined) =>
9+ actionPath?.includes('claude-code-action/')
10+ ? actionPath.split('claude-code-action/')[1]
11+ : undefined
@@ -1,0 +1,3 @@
1+export * from './action-ref-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,60 @@
1+import Deployment from '../../deployment'
2+import IsAnalyticsOff from '../../is-analytics-off'
3+import type { EnvironmentFields } from '../environment-fields'
4+import type { Probe } from '../probe'
5+import { shellOf } from '../shell-of'
6+import { terminalOf } from '../terminal-of'
7+import { actionRefOf } from './action-ref-of'
8+import { githubActionsFieldsOf } from './github-actions-fields-of'
9+import { tagsOf } from './tags-of'
10+
11+/**
12+ * The rows' `env` block from what was probed, valued as the CLI's own is;
13+ * the GitHub Actions fields only inside a workflow, the Linux ones on Linux.
14+ *
15+ * @param probe what was gathered for the session
16+ * @returns the block
17+ */
18+export function environmentFieldsOf(probe: Probe): EnvironmentFields {
19+ const { facts, machine, platform } = probe
20+ const isGithubAction = IsAnalyticsOff.isEnvTruthy(facts.githubActions)
21+ const isLinux = machine.platformRaw === 'linux'
22+
23+ return {
24+ platform,
25+ platformRaw: machine.platformRaw,
26+ arch: machine.arch,
27+ terminal: terminalOf(facts, platform, probe.isInteractive),
28+ shell: shellOf(facts.shellPath),
29+ packageManagers: machine.packageManagers,
30+ runtimes: machine.runtimes,
31+ isCi: IsAnalyticsOff.isEnvTruthy(facts.ci),
32+ isClaubbit: IsAnalyticsOff.isEnvTruthy(facts.claubbit),
33+ isGithubAction,
34+ isClaudeCodeAction: IsAnalyticsOff.isEnvTruthy(facts.claudeCodeAction),
35+ isClaudeCodeRemote: IsAnalyticsOff.isEnvTruthy(facts.claudeCodeRemote),
36+ isLocalAgentMode: facts.entrypoint === 'local-agent',
37+ isConductor: facts.bundleIdentifier === 'com.conductor.app',
38+ deploymentEnvironment: Deployment.deploymentOf(probe.signals, platform),
39+ remoteEnvironmentType: facts.remoteEnvironmentType,
40+ claudeCodeContainerId: facts.containerId,
41+ claudeCodeRemoteSessionId: facts.remoteSessionId,
42+ tags: tagsOf(facts.tags),
43+ githubEventName: isGithubAction ? facts.githubEventName : undefined,
44+ githubActionsRunnerEnvironment: isGithubAction
45+ ? facts.runnerEnvironment
46+ : undefined,
47+ githubActionsRunnerOs: isGithubAction ? facts.runnerOs : undefined,
48+ githubActionRef: isGithubAction
49+ ? actionRefOf(facts.githubActionPath)
50+ : undefined,
51+ githubActionsMetadata: isGithubAction
52+ ? githubActionsFieldsOf(facts)
53+ : undefined,
54+ wslVersion: probe.wslVersion,
55+ linuxDistroId: probe.distro.id,
56+ linuxDistroVersion: probe.distro.version,
57+ linuxKernel: isLinux ? machine.kernel : undefined,
58+ vcs: probe.vcs,
59+ }
60+}
@@ -1,0 +1,26 @@
1+import type { Facts } from '../../../facts'
2+import type EnvironmentFields from '../../environment-fields'
3+
4+/**
5+ * The GitHub Actions ids for the row's `env` block; undefined when the
6+ * workflow names none of the three.
7+ *
8+ * @param facts the variables as read once for the session
9+ * @returns the ids, or undefined
10+ */
11+export function githubActionsFieldsOf(
12+ facts: Facts,
13+): EnvironmentFields.GithubActionsFields | undefined {
14+ const hasAny =
15+ facts.githubActorId !== undefined ||
16+ facts.githubRepositoryId !== undefined ||
17+ facts.githubRepositoryOwnerId !== undefined
18+
19+ return hasAny
20+ ? {
21+ actorId: facts.githubActorId,
22+ repositoryId: facts.githubRepositoryId,
23+ repositoryOwnerId: facts.githubRepositoryOwnerId,
24+ }
25+ : undefined
26+}
@@ -1,0 +1,3 @@
1+export * from './github-actions-fields-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './action-ref-of'
2+export * from './environment-fields-of.js'
3+export * from './github-actions-fields-of'
4+export * from './tags-of'
5+
6+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './tags-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+/**
2+ * The row's `tags` field: the variable split on commas and trimmed, or
3+ * undefined when it names none.
4+ *
5+ * @param variable CLAUDE_CODE_TAGS as read
6+ * @returns the field, or undefined
7+ */
8+export function tagsOf(variable: string | undefined) {
9+ const listed = (variable ?? '')
10+ .split(',')
11+ .map(tag => tag.trim())
12+ .filter(Boolean)
13+
14+ return listed.length > 0 ? listed : undefined
15+}
@@ -1,0 +1,19 @@
1+import type { ConfigLocation } from '../../../config-location'
2+
3+/**
4+ * Where the CLI keeps its global config: `.claude.json` under the config
5+ * directory when one is named, else under the home or profile directory.
6+ *
7+ * @param location the three variables as read
8+ * @returns the file's path, or undefined when no directory is named
9+ */
10+export function globalConfigPath(location: ConfigLocation): string | undefined {
11+ const directory = location.configDir || location.home || location.userProfile
12+
13+ const separator =
14+ directory?.includes('\\') && !directory.includes('/') ? '\\' : '/'
15+
16+ return directory
17+ ? `${directory.replace(/[\\/]+$/, '')}${separator}.claude.json`
18+ : undefined
19+}
@@ -1,0 +1,3 @@
1+export * from './global-config-path.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+/**
2+ * Who the rows are from, as the CLI's global config records it: the
3+ * install's device id, and the signed-in account's ids and address.
4+ *
5+ * Each is undefined when the config has none or cannot be read; the address
6+ * rides only on an internal build's rows, as the CLI's own does.
7+ */
8+export type Identity = {
9+ readonly deviceId: string | undefined
10+ readonly accountUuid: string | undefined
11+ readonly organizationUuid: string | undefined
12+ readonly email: string | undefined
13+}
@@ -1,0 +1,3 @@
1+export type * from './identity.js'
2+
3+export * as default from '.'
@@ -1,0 +1,54 @@
1+import type { ConfigLocation } from '../../config-location'
2+import Entries from '../../entries'
3+import { globalConfigPath } from './global-config-path'
4+import type { Identity } from './identity'
5+import { plausibleUuid } from './plausible-uuid'
6+
7+/**
8+ * Reads the identity the CLI's global config records: the device id it
9+ * minted for this install, and the account it is signed in to.
10+ *
11+ * A config that is missing, unreadable or not JSON yields an identity with
12+ * nothing in it; the address is kept on an internal build alone.
13+ *
14+ * @param read reads a file as text
15+ * @param location where the config is
16+ * @param isInternal whether this is an internal build
17+ * @returns the identity, each field undefined unless the config has it
18+ */
19+export async function identityOf(
20+ read: (path: string) => Promise<string>,
21+ location: ConfigLocation,
22+ isInternal: boolean,
23+): Promise<Identity> {
24+ const path = globalConfigPath(location)
25+
26+ let config: unknown
27+
28+ try {
29+ config = path === undefined ? undefined : JSON.parse(await read(path))
30+ } catch {
31+ config = undefined
32+ }
33+
34+ const record = Entries.isRecord(config) ? config : {}
35+
36+ const account = Entries.isRecord(record.oauthAccount)
37+ ? record.oauthAccount
38+ : {}
39+
40+ const deviceId = record.userID
41+ const email = account.emailAddress
42+
43+ const isDeviceId =
44+ typeof deviceId === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(deviceId)
45+
46+ const isEmailKept = isInternal && typeof email === 'string' && email !== ''
47+
48+ return {
49+ deviceId: isDeviceId ? deviceId : undefined,
50+ accountUuid: plausibleUuid(account.accountUuid),
51+ organizationUuid: plausibleUuid(account.organizationUuid),
52+ email: isEmailKept ? email : undefined,
53+ }
54+}
@@ -1,0 +1,6 @@
1+export * from './global-config-path'
2+export * from './identity'
3+export * from './identity-of.js'
4+export * from './plausible-uuid'
5+
6+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './plausible-uuid.js'
2+export * from './uuid-length-floor'
3+export * from './uuid-length-limit'
4+
5+export * as default from '.'
@@ -1,0 +1,18 @@
1+import { UUID_LENGTH_FLOOR } from './uuid-length-floor'
2+import { UUID_LENGTH_LIMIT } from './uuid-length-limit'
3+
4+/**
5+ * A value the config holds as an account or organization id, when it is a
6+ * string long enough to be one; a number or a trivial string is dropped.
7+ *
8+ * @param value what the config holds under the key
9+ * @returns the id, or undefined
10+ */
11+export function plausibleUuid(value: unknown): string | undefined {
12+ const isPlausible =
13+ typeof value === 'string' &&
14+ value.length >= UUID_LENGTH_FLOOR &&
15+ value.length <= UUID_LENGTH_LIMIT
16+
17+ return isPlausible ? value : undefined
18+}
@@ -1,0 +1,3 @@
1+export * from './uuid-length-floor.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The shortest string taken for an account or organization id, the CLI's
3+ * own floor: long enough to sweep trivial junk, no format assumed.
4+ */
5+export const UUID_LENGTH_FLOOR = 8
@@ -1,0 +1,3 @@
1+export * from './uuid-length-limit.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The longest string taken for an account or organization id or a device
3+ * id; anything longer is not an id and is dropped.
4+ */
5+export const UUID_LENGTH_LIMIT = 128
@@ -1,0 +1,21 @@
1+export * from './client-type-of'
2+export * from './context'
3+export * from './context-of.js'
4+export * from './entrypoint-of'
5+export * from './environment-fields'
6+export * from './environment-fields-of'
7+export * from './identity'
8+export * from './linux-distro-of'
9+export * from './machine'
10+export * from './probe'
11+export * from './probe-of'
12+export * from './remote-hash-of'
13+export * from './session-fields'
14+export * from './session-fields-of'
15+export * from './shell-of'
16+export * from './terminal-of'
17+export * from './vcs-of'
18+export * from './version-of'
19+export * from './wsl-version-of'
20+
21+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './linux-distro'
2+export * from './linux-distro-of.js'
3+export * from './os-release-value'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './linux-distro.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+/**
2+ * What `/etc/os-release` says of a Linux machine for the row: the
3+ * distribution's `ID` and `VERSION_ID`, undefined when absent or unread.
4+ */
5+export type LinuxDistro = {
6+ readonly id: string | undefined
7+ readonly version: string | undefined
8+}
@@ -1,0 +1,26 @@
1+import type { LinuxDistro } from './linux-distro'
2+import { osReleaseValue } from './os-release-value'
3+
4+/**
5+ * Reads the distribution's id and version off `/etc/os-release`, as the
6+ * CLI's own rows do; a file that is missing or unreadable yields neither.
7+ *
8+ * @param readOsRelease reads `/etc/os-release` as text
9+ * @returns the distribution
10+ */
11+export async function linuxDistroOf(
12+ readOsRelease: () => Promise<string>,
13+): Promise<LinuxDistro> {
14+ let content: string
15+
16+ try {
17+ content = await readOsRelease()
18+ } catch {
19+ content = ''
20+ }
21+
22+ return {
23+ id: osReleaseValue(content, 'ID'),
24+ version: osReleaseValue(content, 'VERSION_ID'),
25+ }
26+}
@@ -1,0 +1,3 @@
1+export * from './os-release-value.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+/**
2+ * One key's value in an os-release file, unquoted; undefined when the file
3+ * has no such line.
4+ *
5+ * @param content the file's text
6+ * @param key the key
7+ * @returns the value, or undefined
8+ */
9+export const osReleaseValue = (content: string, key: 'ID' | 'VERSION_ID') =>
10+ content
11+ .split('\n')
12+ .map(line => /^(ID|VERSION_ID)=(.*)$/.exec(line))
13+ .find(match => match?.[1] === key)?.[2]
14+ ?.replace(/^"|"$/g, '')
@@ -1,0 +1,18 @@
1+/**
2+ * The architecture as the CLI's runtime names it, from what `uname -m` or
3+ * Windows' PROCESSOR_ARCHITECTURE says: `x64`, `arm64`, `ia32`, else as is.
4+ *
5+ * @param machine the architecture as the system spells it
6+ * @returns the runtime's spelling
7+ */
8+export function archOf(machine: string) {
9+ const spelled = machine.trim().toLowerCase()
10+
11+ const named: readonly (readonly [boolean, string])[] = [
12+ [spelled === 'x86_64' || spelled === 'amd64', 'x64'],
13+ [spelled === 'aarch64' || spelled === 'arm64', 'arm64'],
14+ [spelled === 'x86' || /^i[3-6]86$/.test(spelled), 'ia32'],
15+ ]
16+
17+ return named.find(([isIt]) => isIt)?.[1] ?? (spelled || 'unknown')
18+}
@@ -1,0 +1,3 @@
1+export * from './arch-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,8 @@
1+export * from './arch-of'
2+export * from './machine'
3+export * from './machine-of.js'
4+export * from './platform-of'
5+export * from './probe-script'
6+export * from './probe-timeout-ms'
7+
8+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './machine.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+/**
2+ * What one probe of the machine answers for the rows' `env` block: the
3+ * system's own name, the architecture, the kernel, and what is on the PATH.
4+ *
5+ * `platformRaw` is the system's name lowercased (`darwin`, `linux`,
6+ * `freebsd`, `win32`); the package managers and runtimes are comma-joined,
7+ * an empty string for none.
8+ */
9+export type Machine = {
10+ readonly platformRaw: string
11+ readonly arch: string
12+ readonly kernel: string | undefined
13+ readonly packageManagers: string
14+ readonly runtimes: string
15+}
@@ -1,0 +1,53 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+import type { Facts } from '../../facts'
4+import { archOf } from './arch-of'
5+import type { Machine } from './machine'
6+import { PROBE_SCRIPT } from './probe-script'
7+
8+/**
9+ * Probes the machine once: on Windows (the `OS` variable says so) from the
10+ * environment alone, elsewhere with one `sh` run of PROBE_SCRIPT.
11+ *
12+ * A probe that fails or exits non-zero leaves the system unnamed
13+ * (`unknown`) and the lists empty; nothing is retried.
14+ *
15+ * @param run runs one program to its end
16+ * @param facts the variables as read, for the Windows answer
17+ * @returns the machine as probed
18+ */
19+export async function machineOf(
20+ run: (argv: readonly string[]) => Promise<ProcessRunResult>,
21+ facts: Facts,
22+): Promise<Machine> {
23+ if (facts.os === 'Windows_NT') {
24+ return {
25+ platformRaw: 'win32',
26+ arch: archOf(facts.processorArchitecture ?? ''),
27+ kernel: undefined,
28+ packageManagers: '',
29+ runtimes: '',
30+ }
31+ }
32+
33+ let result: ProcessRunResult | undefined
34+
35+ try {
36+ result = await run(['sh', '-c', PROBE_SCRIPT])
37+ } catch {
38+ result = undefined
39+ }
40+
41+ const [system = '', kernel = '', machine = '', managers = '', runtimes = ''] =
42+ (result?.exitCode === 0 ? result.stdout.split('\n') : []).map(line =>
43+ line.trim(),
44+ )
45+
46+ return {
47+ platformRaw: system.toLowerCase() || 'unknown',
48+ arch: archOf(machine),
49+ kernel: kernel || undefined,
50+ packageManagers: managers.replace(/,$/, ''),
51+ runtimes: runtimes.replace(/,$/, ''),
52+ }
53+}
@@ -1,0 +1,3 @@
1+export * from './platform-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,17 @@
1+/**
2+ * The row's `platform` as the CLI's own rows have it: the host platform the
3+ * environment names when it is one of the three, else by the system's name.
4+ *
5+ * @param hostPlatform CLAUDE_CODE_HOST_PLATFORM, when set
6+ * @param platformRaw the system's own name lowercased
7+ * @returns `win32`, `darwin`, or `linux` for anything else
8+ */
9+export function platformOf(
10+ hostPlatform: string | undefined,
11+ platformRaw: string,
12+) {
13+ const named = ['win32', 'darwin', 'linux'].find(one => one === hostPlatform)
14+ const isOwnName = platformRaw === 'win32' || platformRaw === 'darwin'
15+
16+ return named ?? (isOwnName ? platformRaw : 'linux')
17+}
@@ -1,0 +1,3 @@
1+export * from './probe-script.js'
2+
3+export * as default from '.'
@@ -1,0 +1,17 @@
1+/**
2+ * The one shell script the plugin runs, once a session, anywhere but
3+ * Windows; five lines come back.
4+ *
5+ * `uname -s`, `uname -r`, `uname -m`, then the package managers found of
6+ * npm, yarn and pnpm comma-joined, then the runtimes found of bun, deno and
7+ * node.
8+ */
9+export const PROBE_SCRIPT = [
10+ 'uname -s',
11+ 'uname -r',
12+ 'uname -m',
13+ 'for c in npm yarn pnpm; do command -v "$c" >/dev/null 2>&1 && ' +
14+ 'printf "%s," "$c"; done; echo',
15+ 'for c in bun deno node; do command -v "$c" >/dev/null 2>&1 && ' +
16+ 'printf "%s," "$c"; done; echo',
17+].join('\n')
@@ -1,0 +1,3 @@
1+export * from './probe-timeout-ms.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How long the one machine probe may run before the host stops it, in
3+ * milliseconds; a stopped probe leaves the machine unnamed.
4+ */
5+export const PROBE_TIMEOUT_MS = 5000
@@ -1,0 +1,3 @@
1+export type * from './probe.js'
2+
3+export * as default from '.'
@@ -1,0 +1,22 @@
1+import type Deployment from '../../deployment'
2+import type { Facts } from '../../facts'
3+import type { Identity } from '../identity'
4+import type { LinuxDistro } from '../linux-distro-of'
5+import type { Machine } from '../machine'
6+
7+/**
8+ * Everything gathered for the session's context before it is shaped: the
9+ * variables, the identity, the machine, the Linux files and the directory.
10+ */
11+export type Probe = {
12+ readonly facts: Facts
13+ readonly signals: readonly Deployment.DeploymentSignal[]
14+ readonly identity: Identity
15+ readonly machine: Machine
16+ readonly platform: string
17+ readonly isInteractive: boolean
18+ readonly distro: LinuxDistro
19+ readonly wslVersion: string | undefined
20+ readonly vcs: string | undefined
21+ readonly remoteHash: string | undefined
22+}
@@ -1,0 +1,5 @@
1+export * from './linux-files'
2+export * from './linux-files-of'
3+export * from './probe-of.js'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export type * from './linux-files.js'
2+
3+export * as default from '.'
@@ -1,0 +1,12 @@
1+import type Deployment from '../../../deployment'
2+import type { LinuxDistro } from '../../linux-distro-of'
3+
4+/**
5+ * What the Linux files say for the rows: the distribution, the WSL release,
6+ * and the two deployments whose signal is a file (EC2, Docker).
7+ */
8+export type LinuxFiles = {
9+ readonly distro: LinuxDistro
10+ readonly wslVersion: string | undefined
11+ readonly fileSignals: readonly Deployment.DeploymentSignal[]
12+}
@@ -1,0 +1,3 @@
1+export * from './linux-files-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,40 @@
1+import type { TelemetryDeps } from '../../../telemetry-deps'
2+import { linuxDistroOf } from '../../linux-distro-of'
3+import { wslVersionOf } from '../../wsl-version-of'
4+import type { LinuxFiles } from '../linux-files'
5+
6+/**
7+ * Reads the four Linux files the rows draw on, on Linux alone; elsewhere,
8+ * and for each file that cannot be read, the answer is empty.
9+ *
10+ * @param deps the calls on the nouns beneath
11+ * @param isLinux whether the machine named itself Linux
12+ * @returns what the files say
13+ */
14+export async function linuxFilesOf(
15+ deps: TelemetryDeps,
16+ isLinux: boolean,
17+): Promise<LinuxFiles> {
18+ const [distro, wslVersion, isEc2, isDocker] = await Promise.all([
19+ isLinux
20+ ? linuxDistroOf(() => deps.read('/etc/os-release'))
21+ : { id: undefined, version: undefined },
22+ isLinux ? wslVersionOf(() => deps.read('/proc/version')) : undefined,
23+ isLinux
24+ ? deps.read('/sys/hypervisor/uuid').then(
25+ uuid => uuid.startsWith('ec2'),
26+ () => false,
27+ )
28+ : false,
29+ isLinux ? deps.exists('/.dockerenv').catch(() => false) : false,
30+ ])
31+
32+ return {
33+ distro,
34+ wslVersion,
35+ fileSignals: [
36+ ['aws-ec2', isEc2],
37+ ['docker', isDocker],
38+ ],
39+ }
40+}
@@ -1,0 +1,54 @@
1+import type { TelemetryDeps } from '../../telemetry-deps'
2+import Identity from '../identity'
3+import Machine from '../machine'
4+import type { Probe } from '../probe'
5+import { remoteHashOf } from '../remote-hash-of'
6+import { vcsOf } from '../vcs-of'
7+import { linuxFilesOf } from './linux-files-of'
8+
9+/**
10+ * Gathers what holds for the whole session through the nouns beneath: the
11+ * variables, the global config, one machine probe, the directory, the remote.
12+ *
13+ * Each read that fails leaves its part empty; the Linux files are read on
14+ * Linux alone.
15+ *
16+ * @param deps the calls on the nouns beneath
17+ * @param isInteractive whether a person is at the prompt
18+ * @returns what was gathered, for context-of to shape
19+ */
20+export async function probeOf(
21+ deps: TelemetryDeps,
22+ isInteractive: boolean,
23+): Promise<Probe> {
24+ const [facts, signals, location, environment, cwd, repo] = await Promise.all([
25+ deps.facts(),
26+ deps.deployment(),
27+ deps.configLocation(),
28+ deps.environment(),
29+ deps.cwd().catch(() => undefined),
30+ deps.repo().catch(() => null),
31+ ])
32+
33+ const [identity, machine, vcs, remoteHash] = await Promise.all([
34+ Identity.identityOf(deps.read, location, environment.userType === 'ant'),
35+ Machine.machineOf(deps.run, facts),
36+ cwd === undefined ? undefined : vcsOf(deps.list, cwd, facts.hasP4Port),
37+ remoteHashOf(repo?.remote),
38+ ])
39+
40+ const files = await linuxFilesOf(deps, machine.platformRaw === 'linux')
41+
42+ return {
43+ facts,
44+ signals: [...signals, ...files.fileSignals],
45+ identity,
46+ machine,
47+ platform: Machine.platformOf(facts.hostPlatform, machine.platformRaw),
48+ isInteractive,
49+ distro: files.distro,
50+ wslVersion: files.wslVersion,
51+ vcs,
52+ remoteHash,
53+ }
54+}
@@ -1,0 +1,12 @@
1+import { HEX_RADIX } from './hex-radix'
2+
3+/**
4+ * A buffer's bytes as lowercase hex, two digits each.
5+ *
6+ * @param buffer the bytes
7+ * @returns the hex text
8+ */
9+export const hexOf = (buffer: ArrayBuffer) =>
10+ [...new Uint8Array(buffer)]
11+ .map(byte => byte.toString(HEX_RADIX).padStart(2, '0'))
12+ .join('')
@@ -1,0 +1,4 @@
1+/**
2+ * Sixteen: the radix a byte is written in as two hex digits.
3+ */
4+export const HEX_RADIX = 16
@@ -1,0 +1,3 @@
1+export * from './hex-radix.js'
2+
3+export * as default from '.'
@@ -1,0 +1,4 @@
1+export * from './hex-of.js'
2+export * from './hex-radix'
3+
4+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './hex-of'
2+export * from './normalized-remote-of'
3+export * from './remote-hash-length'
4+export * from './remote-hash-of.js'
5+
6+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './normalized-remote-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,24 @@
1+/**
2+ * A git remote URL as the CLI normalizes it before hashing: `host/path`
3+ * lowercased, the user and a trailing `.git` dropped.
4+ *
5+ * The scp form (`git@host:owner/repo`) and the URL form (`https://` or
6+ * `ssh://`) alike; undefined for anything else.
7+ *
8+ * @param remote the remote URL as `$.session.repo()` answers it
9+ * @returns the normalized remote, or undefined
10+ */
11+export function normalizedRemoteOf(remote: string): string | undefined {
12+ const trimmed = remote.trim()
13+
14+ const [, host, path] =
15+ /^git@([^:/@]+):(.+?)(?:\.git)?$/.exec(trimmed) ??
16+ /^(?:https?|ssh):\/\/(?:[^@/?#]*@)?([^/?#@]+)\/(.+?)(?:\.git)?$/.exec(
17+ trimmed,
18+ ) ??
19+ []
20+
21+ const isRecognized = host !== undefined && path !== undefined
22+
23+ return isRecognized ? `${host}/${path}`.toLowerCase() : undefined
24+}
@@ -1,0 +1,3 @@
1+export * from './remote-hash-length.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * How many hex digits of the remote's SHA-256 a row carries as `rh`: the
3+ * CLI's own sixteen, so the plugin's rows join where the CLI's do.
4+ */
5+export const REMOTE_HASH_LENGTH = 16
@@ -1,0 +1,24 @@
1+import { hexOf } from './hex-of'
2+import { normalizedRemoteOf } from './normalized-remote-of'
3+import { REMOTE_HASH_LENGTH } from './remote-hash-length'
4+
5+/**
6+ * The rows' `rh`: the first hex digits of the SHA-256 of the session's
7+ * normalized origin remote, the key the CLI's own rows join a repository by.
8+ *
9+ * @param remote the origin remote as `$.session.repo()` answers it
10+ * @returns the hash, or undefined outside a recognized remote
11+ */
12+export async function remoteHashOf(
13+ remote: string | null | undefined,
14+): Promise<string | undefined> {
15+ const normalized = remote ? normalizedRemoteOf(remote) : undefined
16+
17+ if (normalized === undefined) {
18+ return undefined
19+ }
20+
21+ return hexOf(
22+ await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized)),
23+ ).slice(0, REMOTE_HASH_LENGTH)
24+}
@@ -1,0 +1,3 @@
1+export type * from './session-fields.js'
2+
3+export * as default from '.'
@@ -1,0 +1,15 @@
1+/**
2+ * The row fields that are the session's for its whole life, before their
3+ * keys are spelled for the wire.
4+ *
5+ * The client, the entrypoint, the SDK and a benchmark run's ids, each
6+ * undefined when the environment names none.
7+ */
8+export type SessionFields = {
9+ readonly clientType: string
10+ readonly entrypoint: string | undefined
11+ readonly agentSdkVersion: string | undefined
12+ readonly sweBenchRunId: string | undefined
13+ readonly sweBenchInstanceId: string | undefined
14+ readonly sweBenchTaskId: string | undefined
15+}
@@ -1,0 +1,3 @@
1+export * from './session-fields-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,21 @@
1+import type { Facts } from '../../facts'
2+import { clientTypeOf } from '../client-type-of'
3+import { entrypointOf } from '../entrypoint-of'
4+import type { SessionFields } from '../session-fields'
5+import { versionOf } from '../version-of'
6+
7+/**
8+ * The session's constant row fields from the variables as read: the client,
9+ * the entrypoint, the SDK's version and a benchmark run's ids.
10+ *
11+ * @param facts the variables as read once for the session
12+ * @returns the fields
13+ */
14+export const sessionFieldsOf = (facts: Facts): SessionFields => ({
15+ clientType: clientTypeOf(facts),
16+ entrypoint: entrypointOf(facts.entrypoint),
17+ agentSdkVersion: versionOf(facts.agentSdkVersion),
18+ sweBenchRunId: facts.sweBenchRunId || undefined,
19+ sweBenchInstanceId: facts.sweBenchInstanceId || undefined,
20+ sweBenchTaskId: facts.sweBenchTaskId || undefined,
21+})
@@ -1,0 +1,4 @@
1+export * from './known-shells'
2+export * from './shell-of.js'
3+
4+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './known-shells.js'
2+
3+export * as default from '.'
@@ -1,0 +1,23 @@
1+/**
2+ * The shell names a row may carry, the CLI's own list: the login shell's
3+ * basename when it is one of these, else `other`; `none` when unset.
4+ */
5+export const KNOWN_SHELLS: readonly string[] = [
6+ 'zsh',
7+ 'bash',
8+ 'fish',
9+ 'sh',
10+ 'dash',
11+ 'ash',
12+ 'ksh',
13+ 'tcsh',
14+ 'csh',
15+ 'nu',
16+ 'nushell',
17+ 'pwsh',
18+ 'powershell',
19+ 'cmd',
20+ 'elvish',
21+ 'xonsh',
22+ 'ion',
23+]
@@ -1,0 +1,24 @@
1+import { KNOWN_SHELLS } from './known-shells'
2+
3+/**
4+ * The row's `shell`: the login shell's basename when the CLI's list knows
5+ * it, `other` when it does not, `none` when no shell is set.
6+ *
7+ * The path itself, which can name a person, never reaches a row.
8+ *
9+ * @param shellPath SHELL (or COMSPEC on Windows) as read
10+ * @returns the shell's name from the closed list
11+ */
12+export function shellOf(shellPath: string | undefined) {
13+ const base = (shellPath ?? '')
14+ .split(/[/\\]/)
15+ .at(-1)
16+ ?.toLowerCase()
17+ .replace(/\.exe$/, '')
18+
19+ if (!shellPath) {
20+ return 'none'
21+ }
22+
23+ return base !== undefined && KNOWN_SHELLS.includes(base) ? base : 'other'
24+}
@@ -1,0 +1,4 @@
1+export * from './jetbrains-ides'
2+export * from './terminal-of.js'
3+
4+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './jetbrains-ides.js'
2+
3+export * as default from '.'
@@ -1,0 +1,22 @@
1+/**
2+ * The JetBrains IDEs the CLI tells apart by the terminal's bundle
3+ * identifier, in the order it tries them.
4+ */
5+export const JETBRAINS_IDES: readonly string[] = [
6+ 'pycharm',
7+ 'intellij',
8+ 'webstorm',
9+ 'phpstorm',
10+ 'rubymine',
11+ 'clion',
12+ 'goland',
13+ 'rider',
14+ 'datagrip',
15+ 'appcode',
16+ 'dataspell',
17+ 'aqua',
18+ 'gateway',
19+ 'fleet',
20+ 'jetbrains',
21+ 'androidstudio',
22+]
@@ -1,0 +1,74 @@
1+import type { Facts } from '../../facts'
2+import { JETBRAINS_IDES } from './jetbrains-ides'
3+
4+/**
5+ * The row's `terminal` as the CLI names the terminal it runs in, read off
6+ * the same variables in the same order.
7+ *
8+ * The IDE hosts first, then TERM and TERM_PROGRAM, the multiplexers, the
9+ * Linux and Windows terminals, WSL, an ssh session, and what TERM still
10+ * tells; `non-interactive` with no person at the prompt, else `unknown`.
11+ *
12+ * @param facts the variables as read once for the session
13+ * @param platform the row's platform (the JetBrains check differs on darwin)
14+ * @param isInteractive whether a person is at the prompt
15+ * @returns the terminal's name
16+ */
17+export function terminalOf(
18+ facts: Facts,
19+ platform: string,
20+ isInteractive: boolean,
21+) {
22+ const askpass = (facts.vscodeGitAskpassMain ?? '').toLowerCase()
23+ const bundle = (facts.bundleIdentifier ?? '').toLowerCase()
24+ const term = facts.term ?? ''
25+ const termProgram = facts.termProgram ?? ''
26+ const isJediTerm = facts.terminalEmulator === 'JetBrains-JediTerm'
27+ const jetbrains = JETBRAINS_IDES.find(ide => bundle.includes(ide))
28+
29+ const candidates: readonly (readonly [boolean, string])[] = [
30+ [isJediTerm && platform !== 'darwin', 'pycharm'],
31+ [facts.hasCursorTraceId || askpass.includes('cursor'), 'cursor'],
32+ [askpass.includes('windsurf'), 'windsurf'],
33+ [askpass.includes('antigravity'), 'antigravity'],
34+ [bundle.includes('vscodium'), 'codium'],
35+ [bundle.includes('windsurf') || bundle.includes('devin'), 'windsurf'],
36+ [bundle.includes('com.google.android.studio'), 'androidstudio'],
37+ [jetbrains !== undefined, jetbrains ?? ''],
38+ [facts.hasVisualStudioVersion, 'visualstudio'],
39+ [isJediTerm, 'pycharm'],
40+ [term === 'xterm-ghostty', 'ghostty'],
41+ [term.includes('kitty'), 'kitty'],
42+ [/^devin([ -]desktop)?$/i.test(termProgram), 'windsurf'],
43+ [termProgram !== '', termProgram],
44+ [facts.hasTmux, 'tmux'],
45+ [facts.hasSty, 'screen'],
46+ [facts.hasKonsoleVersion, 'konsole'],
47+ [facts.hasGnomeTerminalService, 'gnome-terminal'],
48+ [facts.hasXtermVersion, 'xterm'],
49+ [facts.hasVteVersion, 'vte-based'],
50+ [facts.hasTerminatorUuid, 'terminator'],
51+ [facts.hasKittyWindowId, 'kitty'],
52+ [facts.hasAlacrittyLog, 'alacritty'],
53+ [facts.hasTilixId, 'tilix'],
54+ [facts.hasWtSession, 'windows-terminal'],
55+ [facts.hasSessionName && term === 'cygwin', 'cygwin'],
56+ [facts.msystem !== undefined, (facts.msystem ?? '').toLowerCase()],
57+ [
58+ facts.hasConEmuAnsi || facts.hasConEmuPid || facts.hasConEmuTask,
59+ 'conemu',
60+ ],
61+ [facts.wslDistroName !== undefined, `wsl-${facts.wslDistroName}`],
62+ [
63+ facts.hasSshConnection || facts.hasSshClient || facts.hasSshTty,
64+ 'ssh-session',
65+ ],
66+ [term.includes('alacritty'), 'alacritty'],
67+ [term.includes('rxvt'), 'rxvt'],
68+ [term.includes('termite'), 'termite'],
69+ [term !== '', term],
70+ [!isInteractive, 'non-interactive'],
71+ ]
72+
73+ return candidates.find(([isHeld]) => isHeld)?.[1] ?? 'unknown'
74+}
@@ -1,0 +1,17 @@
1+/**
2+ * The list with each value once, first occurrence kept, order kept.
3+ *
4+ * @param values the list
5+ * @returns the distinct values
6+ */
7+export function distinct(values: readonly string[]): string[] {
8+ const seen = new Set<string>()
9+
10+ return values.filter(value => {
11+ const isSeen = seen.has(value)
12+
13+ seen.add(value)
14+
15+ return !isSeen
16+ })
17+}
@@ -1,0 +1,3 @@
1+export * from './distinct.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+export * from './distinct'
2+export * from './vcs-markers'
3+export * from './vcs-of.js'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './vcs-markers.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+/**
2+ * The entries that mark a working directory as a version-control checkout,
3+ * each with the system it names, in the CLI's own order.
4+ */
5+export const VCS_MARKERS: readonly (readonly [string, string])[] = [
6+ ['.git', 'git'],
7+ ['.hg', 'mercurial'],
8+ ['.svn', 'svn'],
9+ ['.p4config', 'perforce'],
10+ ['$tf', 'tfs'],
11+ ['.tfvc', 'tfs'],
12+ ['.jj', 'jujutsu'],
13+ ['.sl', 'sapling'],
14+]
@@ -1,0 +1,38 @@
1+import type { FsEntry } from 'claude-code'
2+
3+import { distinct } from './distinct'
4+import { VCS_MARKERS } from './vcs-markers'
5+
6+/**
7+ * The row's `vcs`: the version-control systems whose marker is in the
8+ * session's directory, Perforce too when P4PORT is set, comma-joined.
9+ *
10+ * @param list lists a directory
11+ * @param cwd the session's directory
12+ * @param hasP4Port whether P4PORT is set
13+ * @returns the systems found, or undefined for none or an unlistable directory
14+ */
15+export async function vcsOf(
16+ list: (path: string) => Promise<readonly FsEntry[]>,
17+ cwd: string,
18+ hasP4Port: boolean,
19+): Promise<string | undefined> {
20+ let entries: readonly FsEntry[]
21+
22+ try {
23+ entries = await list(cwd)
24+ } catch {
25+ entries = []
26+ }
27+
28+ const names = new Set(entries.map(entry => entry.name))
29+
30+ const found = distinct([
31+ ...(hasP4Port ? ['perforce'] : []),
32+ ...VCS_MARKERS.flatMap(([marker, system]) =>
33+ names.has(marker) ? [system] : [],
34+ ),
35+ ])
36+
37+ return found.length > 0 ? found.join(',') : undefined
38+}
@@ -1,0 +1,3 @@
1+export * from './version-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,13 @@
1+/**
2+ * A version string reduced to what a row may carry, as the CLI reduces the
3+ * Agent SDK's: its semver core, `unknown` as said, else `other`.
4+ *
5+ * @param version the variable as read; undefined stays undefined
6+ * @returns the version for the row, or undefined
7+ */
8+export function versionOf(version: string | undefined) {
9+ const isKept = version === undefined || version === 'unknown'
10+ const core = /^\d{1,8}\.\d{1,8}\.\d{1,8}(?!\d)/.exec(version ?? '')?.[0]
11+
12+ return isKept ? version : (core ?? 'other')
13+}
@@ -1,0 +1,3 @@
1+export * from './wsl-version-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,22 @@
1+/**
2+ * The WSL release a Linux kernel string names, as the CLI reads
3+ * `/proc/version`: the digit after `WSL`, `1` for the `Microsoft` kernels.
4+ *
5+ * @param readProcVersion reads `/proc/version` as text
6+ * @returns the WSL version, or undefined off WSL or when the file is unread
7+ */
8+export async function wslVersionOf(
9+ readProcVersion: () => Promise<string>,
10+): Promise<string | undefined> {
11+ let kernel: string
12+
13+ try {
14+ kernel = (await readProcVersion()).toLowerCase()
15+ } catch {
16+ return undefined
17+ }
18+
19+ const isMicrosoft = kernel.includes('microsoft')
20+
21+ return /wsl(\d+)/.exec(kernel)?.[1] ?? (isMicrosoft ? '1' : undefined)
22+}
@@ -1,0 +1,6 @@
1+import type { DEPLOYMENTS } from '../deployments'
2+
3+/**
4+ * One of the deployments a row can name (DEPLOYMENTS).
5+ */
6+export type DeploymentName = (typeof DEPLOYMENTS)[number]
@@ -1,0 +1,3 @@
1+export type * from './deployment-name.js'
2+
3+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type { DeploymentSignal } from './deployment-signal'
2+import { DEPLOYMENTS } from './deployments'
3+
4+/**
5+ * The row's `deployment_environment`: the first deployment in the CLI's
6+ * order whose signal holds, else `unknown-<platform>` as it names none.
7+ *
8+ * @param signals each deployment read, with whether its signal holds
9+ * @param platform the row's platform, for the fallback
10+ * @returns the deployment's name
11+ */
12+export const deploymentOf = (
13+ signals: readonly DeploymentSignal[],
14+ platform: string,
15+) =>
16+ DEPLOYMENTS.find(deployment =>
17+ signals.some(([name, isHeld]) => isHeld && name === deployment),
18+ ) ?? `unknown-${platform}`
@@ -1,0 +1,7 @@
1+import type { DeploymentName } from '../deployment-name'
2+
3+/**
4+ * One deployment and whether its signal holds: a variable set, on, or
5+ * holding the value the CLI looks for, or one of the two marker files.
6+ */
7+export type DeploymentSignal = readonly [DeploymentName, boolean]
@@ -1,0 +1,3 @@
1+export type * from './deployment-signal.js'
2+
3+export * as default from '.'
@@ -1,0 +1,43 @@
1+/**
2+ * Every deployment a row can name, in the order the CLI's own detection
3+ * tries them; the first whose signal holds names the row.
4+ *
5+ * Cloud development environments, then cloud platforms, CI, and container
6+ * orchestration last.
7+ */
8+export const DEPLOYMENTS = [
9+ 'codespaces',
10+ 'gitpod',
11+ 'coder',
12+ 'devpod',
13+ 'daytona',
14+ 'gcp-cloud-workstations',
15+ 'aws-cloud9',
16+ 'replit',
17+ 'glitch',
18+ 'vercel',
19+ 'railway',
20+ 'render',
21+ 'netlify',
22+ 'heroku',
23+ 'fly.io',
24+ 'cloudflare-pages',
25+ 'deno-deploy',
26+ 'aws-lambda',
27+ 'aws-fargate',
28+ 'aws-ecs',
29+ 'aws-ec2',
30+ 'gcp-cloud-run',
31+ 'gcp',
32+ 'azure-app-service',
33+ 'azure-functions',
34+ 'digitalocean-app-platform',
35+ 'huggingface-spaces',
36+ 'github-actions',
37+ 'gitlab-ci',
38+ 'circleci',
39+ 'buildkite',
40+ 'ci',
41+ 'kubernetes',
42+ 'docker',
43+] as const
@@ -1,0 +1,3 @@
1+export * from './deployments.js'
2+
3+export * as default from '.'
@@ -1,0 +1,6 @@
1+export * from './deployment-name'
2+export * from './deployment-of.js'
3+export * from './deployment-signal'
4+export * from './deployments'
5+
6+export * as default from '.'
@@ -1,34 +1,23 @@
1-import type { Fields } from './fields'
2-import type { RowSession } from './row-session'
1+import type Batching from '../batching'
2+import type { BatchStamp } from './batch-stamp'
3+import { eventDataOf } from './event-data-of'
4+import { wireOf } from './wire-of'
35
46 /**
5- * The first-party event batch for one entry, shaped as the CLI's own
6- * event exporter shapes its batches.
7+ * The first-party event batch for the queued rows, shaped as the CLI's own
8+ * event exporter shapes its batches: one ClaudeCodeInternalEvent per row.
79 *
8- * The ClaudeCodeInternalEvent JSON, one event per batch; the properties ride
9- * as base64 JSON in `additional_metadata`, the field the exporter uses;
10- * `user_type` is what the environment's USER_TYPE says of the build.
11- *
12- * @param fields the entry as checked
13- * @param session the session's id and model, and the build's user type
14- * @returns the batch's JSON text, ready to send to the exporter
10+ * @param rows the rows as they were queued, oldest first
11+ * @param stamp what the batch stamps on every row
12+ * @returns the batch's JSON text, ready to POST to the ingest
1513 */
16-export function batchOf(fields: Fields, session: RowSession) {
17- const metadata = JSON.stringify(fields.props)
18-
19- return JSON.stringify({
20- events: [
21- {
22- event_type: 'ClaudeCodeInternalEvent',
23- event_data: {
24- event_name: fields.name,
25- client_timestamp: new Date().toISOString(),
26- session_id: session.sessionId,
27- model: session.model,
28- user_type: session.userType,
29- additional_metadata: btoa(metadata),
30- },
31- },
32- ],
14+export const batchOf = (
15+ rows: readonly Batching.PendingRow[],
16+ stamp: BatchStamp,
17+) =>
18+ JSON.stringify({
19+ events: rows.map(row => ({
20+ event_type: 'ClaudeCodeInternalEvent',
21+ event_data: wireOf(eventDataOf(row, stamp)),
22+ })),
3323 })
34-}
@@ -1,0 +1,17 @@
1+import type { Context } from '../../context'
2+
3+/**
4+ * What one batch stamps on each of its rows beside the row's own fields:
5+ * the session as read when the batch went out, and its context.
6+ *
7+ * `userType` is `ant` or `external` as USER_TYPE says of the build;
8+ * `isClaudeAiAuth` follows the credential the batch is sent with.
9+ */
10+export type BatchStamp = {
11+ readonly sessionId: string
12+ readonly model: string
13+ readonly userType: string
14+ readonly isInteractive: boolean
15+ readonly isClaudeAiAuth: boolean
16+ readonly context: Context
17+}
@@ -1,0 +1,3 @@
1+export type * from './batch-stamp.js'
2+
3+export * as default from '.'
@@ -1,0 +1,21 @@
1+import type { Context } from '../../../context'
2+
3+/**
4+ * The row's `auth`: the signed-in account's and organization's ids, or
5+ * undefined when the identity holds neither (an API key, no login).
6+ *
7+ * @param identity who the rows are from
8+ * @returns the ids, or undefined
9+ */
10+export function authOf(identity: Context['identity']) {
11+ const hasEither =
12+ identity.accountUuid !== undefined ||
13+ identity.organizationUuid !== undefined
14+
15+ return hasEither
16+ ? {
17+ accountUuid: identity.accountUuid,
18+ organizationUuid: identity.organizationUuid,
19+ }
20+ : undefined
21+}
@@ -1,0 +1,3 @@
1+export * from './auth-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,31 @@
1+import type Batching from '../../batching'
2+import type { BatchStamp } from '../batch-stamp'
3+import { authOf } from './auth-of'
4+import { metadataOf } from './metadata-of'
5+
6+/**
7+ * One row as a ClaudeCodeInternalEvent, keyed as the plugin builds it;
8+ * wireOf spells it for the ingest.
9+ *
10+ * Its id, name and time, who and where it is from, the session's fields,
11+ * its `env` block, and its properties in `additionalMetadata`.
12+ *
13+ * @param row the row as it was queued
14+ * @param stamp what the batch stamps on every row
15+ * @returns the event's data
16+ */
17+export const eventDataOf = (row: Batching.PendingRow, stamp: BatchStamp) => ({
18+ eventId: row.eventId,
19+ eventName: row.fields.name,
20+ clientTimestamp: row.loggedAt,
21+ deviceId: stamp.context.identity.deviceId,
22+ email: stamp.context.identity.email,
23+ auth: authOf(stamp.context.identity),
24+ sessionId: stamp.sessionId,
25+ model: stamp.model,
26+ userType: stamp.userType,
27+ isInteractive: stamp.isInteractive,
28+ ...stamp.context.session,
29+ env: { ...stamp.context.environment, isClaudeAiAuth: stamp.isClaudeAiAuth },
30+ additionalMetadata: metadataOf(row, stamp.context.remoteHash),
31+})
@@ -1,0 +1,5 @@
1+export * from './auth-of'
2+export * from './event-data-of.js'
3+export * from './metadata-of'
4+
5+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './metadata-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+import type Batching from '../../../batching'
2+
3+/**
4+ * The row's `additional_metadata`: its properties as base64 JSON, the
5+ * repository's remote hash beside them as the CLI's own rows carry it.
6+ *
7+ * @param row the row as it was queued
8+ * @param remoteHash the session's remote hash, when it is in a repository
9+ * @returns the base64 text
10+ */
11+export const metadataOf = (
12+ row: Batching.PendingRow,
13+ remoteHash: string | undefined,
14+) => btoa(JSON.stringify({ rh: remoteHash, ...row.fields.props }))
@@ -1,4 +1,5 @@
11 export * from './batch-of.js'
2+export * from './batch-stamp'
23 export * from './checked-fields.js'
34 export * from './checked-mark.js'
45 export * from './checked-props'
@@ -6,6 +7,7 @@
67 export * from './choice-token'
78 export * from './choices-limit'
89 export * from './core-event-prefix'
10+export * from './event-data-of'
911 export * from './event-prefix'
1012 export * from './feature-prefix'
1113 export * from './fields'
@@ -19,7 +21,7 @@
1921 export * from './method'
2022 export * from './prop-limit'
2123 export * from './refusal'
22-export * from './row-session'
2324 export * from './token'
25+export * from './wire-of'
2426
2527 export * as default from '.'
Dmods/telemetry/hooks/entries/row-session/index.ts+0 -3
@@ -1,3 +1,0 @@
1-export type * from './row-session.js'
2-
3-export * as default from '.'
Dmods/telemetry/hooks/entries/row-session/row-session.ts+0 -9
@@ -1,9 +1,0 @@
1-/**
2- * What one row says of the session that sent it: its id, its model, and
3- * the build's user type (`ant` or `external`) as the environment names it.
4- */
5-export type RowSession = {
6- sessionId: string
7- model: string
8- userType: string
9-}
@@ -1,0 +1,3 @@
1+export * from './wire-of.js'
2+
3+export * as default from '.'
@@ -1,0 +1,24 @@
1+import { isRecord } from '../is-record'
2+
3+/**
4+ * A value with every object key respelled for the event-logging ingest:
5+ * `platformRaw` as `platform_raw`, all the way down; arrays and leaves kept.
6+ *
7+ * @param value the row, or a part of it, keyed as the plugin builds it
8+ * @returns the same value keyed as the ingest reads it
9+ */
10+export function wireOf(value: unknown): unknown {
11+ const isList = Array.isArray(value)
12+ const isObject = isRecord(value)
13+
14+ return isList
15+ ? value.map(wireOf)
16+ : isObject
17+ ? Object.fromEntries(
18+ Object.entries(value).map(([key, member]) => [
19+ key.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`),
20+ wireOf(member),
21+ ]),
22+ )
23+ : value
24+}
@@ -1,16 +1,19 @@
11 /**
2- * What the plugin reads of the environment before each row: the
2+ * What the plugin reads of the environment before each batch: the
33 * build's user type and every switch that turns the CLI's analytics off.
44 *
55 * Each field is the variable's value as `$.env.get` answers it, undefined
6- * when unset; analytics-off/ decides what they mean together.
6+ * when unset; is-analytics-off/ decides what they mean together.
77 */
88 export type Environment = {
99 readonly userType: string | undefined
10+ readonly nodeEnv: string | undefined
1011 readonly disableTelemetry: string | undefined
1112 readonly disableNonessentialTraffic: string | undefined
1213 readonly doNotTrack: string | undefined
1314 readonly customOauthUrl: string | undefined
15+ readonly useGateway: string | undefined
16+ readonly providerManagedByHost: string | undefined
1417 readonly useBedrock: string | undefined
1518 readonly useVertex: string | undefined
1619 readonly useFoundry: string | undefined
@@ -1,0 +1,66 @@
1+/**
2+ * What the plugin reads of the environment once a session to describe it in
3+ * every row, as the CLI's own rows describe theirs.
4+ *
5+ * Each field is one variable as `$.env.get` answers it, undefined when
6+ * unset, or only whether it is set where its value is a secret or names a
7+ * person or a host; context/ reduces each to the value the CLI's column has.
8+ */
9+export type Facts = {
10+ readonly entrypoint: string | undefined
11+ readonly agentSdkVersion: string | undefined
12+ readonly hostPlatform: string | undefined
13+ readonly os: string | undefined
14+ readonly processorArchitecture: string | undefined
15+ readonly shellPath: string | undefined
16+ readonly ci: string | undefined
17+ readonly claubbit: string | undefined
18+ readonly githubActions: string | undefined
19+ readonly claudeCodeAction: string | undefined
20+ readonly claudeCodeRemote: string | undefined
21+ readonly remoteEnvironmentType: string | undefined
22+ readonly containerId: string | undefined
23+ readonly remoteSessionId: string | undefined
24+ readonly tags: string | undefined
25+ readonly hasSessionAccessToken: boolean
26+ readonly hasSessionIngressTokenFile: boolean
27+ readonly hasWebsocketAuthFileDescriptor: boolean
28+ readonly githubEventName: string | undefined
29+ readonly runnerEnvironment: string | undefined
30+ readonly runnerOs: string | undefined
31+ readonly githubActionPath: string | undefined
32+ readonly githubActorId: string | undefined
33+ readonly githubRepositoryId: string | undefined
34+ readonly githubRepositoryOwnerId: string | undefined
35+ readonly sweBenchRunId: string | undefined
36+ readonly sweBenchInstanceId: string | undefined
37+ readonly sweBenchTaskId: string | undefined
38+ readonly hasP4Port: boolean
39+ readonly wslDistroName: string | undefined
40+ readonly hasCursorTraceId: boolean
41+ readonly vscodeGitAskpassMain: string | undefined
42+ readonly bundleIdentifier: string | undefined
43+ readonly hasVisualStudioVersion: boolean
44+ readonly terminalEmulator: string | undefined
45+ readonly term: string | undefined
46+ readonly termProgram: string | undefined
47+ readonly hasTmux: boolean
48+ readonly hasSty: boolean
49+ readonly hasKonsoleVersion: boolean
50+ readonly hasGnomeTerminalService: boolean
51+ readonly hasXtermVersion: boolean
52+ readonly hasVteVersion: boolean
53+ readonly hasTerminatorUuid: boolean
54+ readonly hasKittyWindowId: boolean
55+ readonly hasAlacrittyLog: boolean
56+ readonly hasTilixId: boolean
57+ readonly hasWtSession: boolean
58+ readonly hasSessionName: boolean
59+ readonly msystem: string | undefined
60+ readonly hasConEmuAnsi: boolean
61+ readonly hasConEmuPid: boolean
62+ readonly hasConEmuTask: boolean
63+ readonly hasSshConnection: boolean
64+ readonly hasSshClient: boolean
65+ readonly hasSshTty: boolean
66+}
@@ -1,0 +1,3 @@
1+export type * from './facts.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+import type { Caught } from 'claude-code'
2+
3+import { REFUSED } from '../refused'
4+
5+/**
6+ * The gate's `.catch`: a gate that threw before going on refuses the call;
7+ * a refusal from beneath (a malformed entry) is passed up as it is.
8+ *
9+ * @param e the entry as the caller passed it
10+ * @param next the replay-safe continuation the catch is handed
11+ * @returns beneath's own answer replayed, or the refusal
12+ */
13+export const caught = <E, R>(e: E, next: ((e: E) => R) & Caught) =>
14+ next.called ? next(e) : REFUSED
@@ -1,0 +1,3 @@
1+export * from './caught.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+import type { Origin } from 'claude-code'
2+
3+/**
4+ * What the gate reads of the continuation it is handed: the hooks beneath
5+ * to go on to, and who raised the call, as the host stamped it.
6+ */
7+export type GateNext<E, R> = ((e: E) => R) & { readonly origin?: Origin }
@@ -1,0 +1,3 @@
1+export type * from './gate-next.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+export * from './caught'
2+export * from './gate-next'
3+export * from './refused'
4+export * from './served'
5+export * from './served-tiers'
6+
7+export * as default from '.'
@@ -1,0 +1,3 @@
1+export * from './refused.js'
2+
3+export * as default from '.'
@@ -1,0 +1,7 @@
1+/**
2+ * The answer a caller outside the served tiers gets from `$.telemetry`: its
3+ * promise rejects with this, and the host notes it in the debug log.
4+ */
5+export const REFUSED = {
6+ deny: '$.telemetry serves the plugins built into Claude Code alone',
7+} as const
@@ -1,0 +1,3 @@
1+export * from './served.js'
2+
3+export * as default from '.'
@@ -1,0 +1,19 @@
1+import type { GateNext } from '../gate-next'
2+import { REFUSED } from '../refused'
3+import { SERVED_TIERS } from '../served-tiers'
4+
5+/**
6+ * The gate on `telemetry.log` and `telemetry.mark`: a caller seated in
7+ * SERVED_TIERS goes on beneath, any other is REFUSED; no origin throws.
8+ *
9+ * @param e the entry as the caller passed it
10+ * @param next the hooks beneath, carrying who raised the call
11+ * @returns what beneath answers, or the refusal
12+ */
13+export function served<E, R>(e: E, next: GateNext<E, R>): R | typeof REFUSED {
14+ if (next.origin?.tier === undefined) {
15+ throw new Error('$.telemetry: the call names no origin')
16+ }
17+
18+ return SERVED_TIERS.includes(next.origin.tier) ? next(e) : REFUSED
19+}
@@ -1,0 +1,3 @@
1+export * from './served-tiers.js'
2+
3+export * as default from '.'
@@ -1,0 +1,5 @@
1+/**
2+ * The tiers `$.telemetry` serves: the plugins bundled in the binary, and the
3+ * engine itself. A caller seated anywhere else is refused, on every build.
4+ */
5+export const SERVED_TIERS: readonly string[] = ['builtin', 'core']
@@ -1,4 +1,6 @@
11 {
2- "description": "Plugin analytics: an engine.create step adds $.telemetry (log, mark) over $.session, $.http and $.env beneath; sends nothing wherever the CLI's analytics are off",
3- "modules": ["./register.ts"]
2+ "description": "Plugin analytics: an engine.create step adds $.telemetry (log, mark) over $.session, $.env, $.settings, $.fs, $.process, $.clock and $.http beneath; rows go out in batches; sends nothing wherever the CLI's analytics are off",
3+ "modules": [
4+ "./register.ts"
5+ ]
46 }
@@ -1,7 +1,15 @@
1+export * from './batching'
2+export * from './config-location'
3+export * from './context'
4+export * from './deployment'
15 export * from './entries'
26 export * from './environment'
7+export * from './facts'
8+export * from './gate'
39 export * from './is-analytics-off'
10+export * from './policy-pins'
411 export * from './register.js'
12+export * from './sender'
513 export * from './telemetry-deps'
614 export * from './telemetry-of'
715
@@ -1,34 +1,44 @@
11 import type { Environment } from '../environment'
2+import type { PolicyPins } from '../policy-pins'
23 import { isEnvSet } from './is-env-set'
34 import { isEnvTruthy } from './is-env-truthy'
45
56 /**
67 * Whether the CLI's own analytics would be off here, read off the
7- * environment alone: then the plugin sends nothing either.
8+ * environment and the managed policy: then the plugin sends nothing either.
89 *
9- * Off when DISABLE_TELEMETRY or the nonessential-traffic switch is set at
10- * all or DO_NOT_TRACK is on, on any third-party provider (no first-party
11- * credential rides there), and on a deployment with its own OAuth URL.
10+ * Off under test, when DISABLE_TELEMETRY or the nonessential-traffic switch
11+ * is set at all or DO_NOT_TRACK is on, on a third-party provider the host
12+ * does not manage, on a gateway, and on a deployment with its own OAuth URL.
1213 *
13- * @param environment the variables as read once for the session
14+ * @param environment the switches as read for this batch
15+ * @param policy the managed settings' gateway pins
1416 * @returns true when no row may be sent
1517 */
16-export function isAnalyticsOff(environment: Environment) {
18+export function isAnalyticsOff(environment: Environment, policy: PolicyPins) {
1719 const isPrivate =
20+ environment.nodeEnv === 'test' ||
1821 isEnvSet(environment.disableTelemetry) ||
1922 isEnvSet(environment.disableNonessentialTraffic) ||
2023 isEnvTruthy(environment.doNotTrack)
2124
22- const isThirdParty = [
23- environment.useBedrock,
24- environment.useVertex,
25- environment.useFoundry,
26- environment.useAnthropicAws,
27- environment.useAnthropicGoogleCloud,
28- environment.useMantle,
29- ].some(isEnvTruthy)
25+ const isThirdParty =
26+ !isEnvTruthy(environment.providerManagedByHost) &&
27+ [
28+ environment.useBedrock,
29+ environment.useVertex,
30+ environment.useFoundry,
31+ environment.useAnthropicAws,
32+ environment.useAnthropicGoogleCloud,
33+ environment.useMantle,
34+ ].some(isEnvTruthy)
3035
36+ const isGateway =
37+ isEnvTruthy(environment.useGateway) ||
38+ policy.forceLoginMethod === 'gateway' ||
39+ policy.forceLoginGatewayUrl !== undefined
40+
3141 const isCustomDeployment = isEnvSet(environment.customOauthUrl?.trim())
3242
33- return isPrivate || isThirdParty || isCustomDeployment
43+ return isPrivate || isThirdParty || isGateway || isCustomDeployment
3444 }
@@ -1,0 +1,3 @@
1+export type * from './policy-pins.js'
2+
3+export * as default from '.'
@@ -1,0 +1,10 @@
1+/**
2+ * The two managed-settings keys that pin a session to a cloud gateway, as
3+ * `$.settings.read({ source: "policy" })` answers them.
4+ *
5+ * A session so pinned sends its rows nowhere, as the CLI's own analytics do.
6+ */
7+export type PolicyPins = {
8+ readonly forceLoginMethod?: unknown
9+ readonly forceLoginGatewayUrl?: unknown
10+}
@@ -1,32 +1,64 @@
11 import type { EngineInterface, On } from 'claude-code'
22
3+import Context from './context'
4+import Gate from './gate'
5+import IsAnalyticsOff from './is-analytics-off'
6+import type { Sender } from './sender'
37 import { telemetryOf } from './telemetry-of'
48
59 /**
6- * Registers the plugin's one hook: its engine.create step adds
7- * `$.telemetry` over the nouns beneath, the plugin's own `$` as core built.
10+ * Registers the plugin's hooks: its engine.create step adds `$.telemetry`
11+ * over the nouns beneath; session.start and session.end feed and flush it.
812 *
9- * `log` runs after the fold, reaching `$.session` and `$.http` through the
10- * nouns beneath; each is one call on them.
13+ * `log` and `mark` queue a row for a built-in caller and refuse any other;
14+ * a gate that throws refuses too, a refusal from beneath stays as it is. A
15+ * batch goes out on a timer, when full, and when the session ends.
1116 *
1217 * @param on the engine's registrar
1318 */
1419 export function register(on: On) {
15- on('engine.create', async ($, e, next) => {
20+ let sender: Sender | undefined
21+ let isInteractive: boolean | undefined
22+
23+ on('telemetry.*', (_$, e, next) => Gate.served(e, next)).catch(
24+ (_$, e, next) => Gate.caught(e, next),
25+ )
26+
27+ on('session.start', (_$, e, next) => {
28+ isInteractive = e.isInteractive
29+
30+ return next(e)
31+ })
32+
33+ on('session.end', async (_$, e, next) => {
34+ await sender?.flush()
35+
36+ return next(e)
37+ })
38+
39+ on('engine.create', async (_$, e, next) => {
1640 const beneath = await next(e)
1741
18- const telemetry: EngineInterface['telemetry'] = telemetryOf({
42+ sender = telemetryOf({
1943 authorize: () => beneath.session.authorize(),
2044 id: () => beneath.session.id(),
2145 model: () => beneath.session.model(),
46+ isInteractive: async () =>
47+ isInteractive ??
48+ (await beneath.session.surfaces()).includes('terminal'),
2249 environment: async () => ({
2350 userType: await beneath.env.get('USER_TYPE'),
51+ nodeEnv: await beneath.env.get('NODE_ENV'),
2452 disableTelemetry: await beneath.env.get('DISABLE_TELEMETRY'),
2553 disableNonessentialTraffic: await beneath.env.get(
2654 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
2755 ),
2856 doNotTrack: await beneath.env.get('DO_NOT_TRACK'),
2957 customOauthUrl: await beneath.env.get('CLAUDE_CODE_CUSTOM_OAUTH_URL'),
58+ useGateway: await beneath.env.get('CLAUDE_CODE_USE_GATEWAY'),
59+ providerManagedByHost: await beneath.env.get(
60+ 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST',
61+ ),
3062 useBedrock: await beneath.env.get('CLAUDE_CODE_USE_BEDROCK'),
3163 useVertex: await beneath.env.get('CLAUDE_CODE_USE_VERTEX'),
3264 useFoundry: await beneath.env.get('CLAUDE_CODE_USE_FOUNDRY'),
@@ -36,9 +68,286 @@
3668 ),
3769 useMantle: await beneath.env.get('CLAUDE_CODE_USE_MANTLE'),
3870 }),
71+ policy: () => beneath.settings.read({ source: 'policy' }),
72+ facts: async () => ({
73+ entrypoint: await beneath.env.get('CLAUDE_CODE_ENTRYPOINT'),
74+ agentSdkVersion: await beneath.env.get('CLAUDE_AGENT_SDK_VERSION'),
75+ hostPlatform: await beneath.env.get('CLAUDE_CODE_HOST_PLATFORM'),
76+ os: await beneath.env.get('OS'),
77+ processorArchitecture: await beneath.env.get('PROCESSOR_ARCHITECTURE'),
78+ shellPath:
79+ (await beneath.env.get('SHELL')) ??
80+ (await beneath.env.get('COMSPEC')),
81+ ci: await beneath.env.get('CI'),
82+ claubbit: await beneath.env.get('CLAUBBIT'),
83+ githubActions: await beneath.env.get('GITHUB_ACTIONS'),
84+ claudeCodeAction: await beneath.env.get('CLAUDE_CODE_ACTION'),
85+ claudeCodeRemote: await beneath.env.get('CLAUDE_CODE_REMOTE'),
86+ remoteEnvironmentType: await beneath.env.get(
87+ 'CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE',
88+ ),
89+ containerId: await beneath.env.get('CLAUDE_CODE_CONTAINER_ID'),
90+ remoteSessionId: await beneath.env.get('CLAUDE_CODE_REMOTE_SESSION_ID'),
91+ tags: await beneath.env.get('CLAUDE_CODE_TAGS'),
92+ hasSessionAccessToken: IsAnalyticsOff.isEnvSet(
93+ await beneath.env.get('CLAUDE_CODE_SESSION_ACCESS_TOKEN'),
94+ ),
95+ hasSessionIngressTokenFile: IsAnalyticsOff.isEnvSet(
96+ await beneath.env.get('CLAUDE_SESSION_INGRESS_TOKEN_FILE'),
97+ ),
98+ hasWebsocketAuthFileDescriptor: IsAnalyticsOff.isEnvSet(
99+ await beneath.env.get('CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR'),
100+ ),
101+ githubEventName: await beneath.env.get('GITHUB_EVENT_NAME'),
102+ runnerEnvironment: await beneath.env.get('RUNNER_ENVIRONMENT'),
103+ runnerOs: await beneath.env.get('RUNNER_OS'),
104+ githubActionPath: await beneath.env.get('GITHUB_ACTION_PATH'),
105+ githubActorId: await beneath.env.get('GITHUB_ACTOR_ID'),
106+ githubRepositoryId: await beneath.env.get('GITHUB_REPOSITORY_ID'),
107+ githubRepositoryOwnerId: await beneath.env.get(
108+ 'GITHUB_REPOSITORY_OWNER_ID',
109+ ),
110+ sweBenchRunId: await beneath.env.get('SWE_BENCH_RUN_ID'),
111+ sweBenchInstanceId: await beneath.env.get('SWE_BENCH_INSTANCE_ID'),
112+ sweBenchTaskId: await beneath.env.get('SWE_BENCH_TASK_ID'),
113+ hasP4Port: IsAnalyticsOff.isEnvSet(await beneath.env.get('P4PORT')),
114+ wslDistroName: await beneath.env.get('WSL_DISTRO_NAME'),
115+ hasCursorTraceId: IsAnalyticsOff.isEnvSet(
116+ await beneath.env.get('CURSOR_TRACE_ID'),
117+ ),
118+ vscodeGitAskpassMain: await beneath.env.get('VSCODE_GIT_ASKPASS_MAIN'),
119+ bundleIdentifier: await beneath.env.get('__CFBundleIdentifier'),
120+ hasVisualStudioVersion: IsAnalyticsOff.isEnvSet(
121+ await beneath.env.get('VisualStudioVersion'),
122+ ),
123+ terminalEmulator: await beneath.env.get('TERMINAL_EMULATOR'),
124+ term: await beneath.env.get('TERM'),
125+ termProgram: await beneath.env.get('TERM_PROGRAM'),
126+ hasTmux: IsAnalyticsOff.isEnvSet(await beneath.env.get('TMUX')),
127+ hasSty: IsAnalyticsOff.isEnvSet(await beneath.env.get('STY')),
128+ hasKonsoleVersion: IsAnalyticsOff.isEnvSet(
129+ await beneath.env.get('KONSOLE_VERSION'),
130+ ),
131+ hasGnomeTerminalService: IsAnalyticsOff.isEnvSet(
132+ await beneath.env.get('GNOME_TERMINAL_SERVICE'),
133+ ),
134+ hasXtermVersion: IsAnalyticsOff.isEnvSet(
135+ await beneath.env.get('XTERM_VERSION'),
136+ ),
137+ hasVteVersion: IsAnalyticsOff.isEnvSet(
138+ await beneath.env.get('VTE_VERSION'),
139+ ),
140+ hasTerminatorUuid: IsAnalyticsOff.isEnvSet(
141+ await beneath.env.get('TERMINATOR_UUID'),
142+ ),
143+ hasKittyWindowId: IsAnalyticsOff.isEnvSet(
144+ await beneath.env.get('KITTY_WINDOW_ID'),
145+ ),
146+ hasAlacrittyLog: IsAnalyticsOff.isEnvSet(
147+ await beneath.env.get('ALACRITTY_LOG'),
148+ ),
149+ hasTilixId: IsAnalyticsOff.isEnvSet(await beneath.env.get('TILIX_ID')),
150+ hasWtSession: IsAnalyticsOff.isEnvSet(
151+ await beneath.env.get('WT_SESSION'),
152+ ),
153+ hasSessionName: IsAnalyticsOff.isEnvSet(
154+ await beneath.env.get('SESSIONNAME'),
155+ ),
156+ msystem: await beneath.env.get('MSYSTEM'),
157+ hasConEmuAnsi: IsAnalyticsOff.isEnvSet(
158+ await beneath.env.get('ConEmuANSI'),
159+ ),
160+ hasConEmuPid: IsAnalyticsOff.isEnvSet(
161+ await beneath.env.get('ConEmuPID'),
162+ ),
163+ hasConEmuTask: IsAnalyticsOff.isEnvSet(
164+ await beneath.env.get('ConEmuTask'),
165+ ),
166+ hasSshConnection: IsAnalyticsOff.isEnvSet(
167+ await beneath.env.get('SSH_CONNECTION'),
168+ ),
169+ hasSshClient: IsAnalyticsOff.isEnvSet(
170+ await beneath.env.get('SSH_CLIENT'),
171+ ),
172+ hasSshTty: IsAnalyticsOff.isEnvSet(await beneath.env.get('SSH_TTY')),
173+ }),
174+ deployment: async () => [
175+ [
176+ 'codespaces',
177+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('CODESPACES')),
178+ ],
179+ [
180+ 'gitpod',
181+ IsAnalyticsOff.isEnvSet(await beneath.env.get('GITPOD_WORKSPACE_ID')),
182+ ],
183+ ['coder', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('CODER'))],
184+ [
185+ 'coder',
186+ IsAnalyticsOff.isEnvSet(
187+ await beneath.env.get('CODER_WORKSPACE_NAME'),
188+ ),
189+ ],
190+ ['devpod', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('DEVPOD'))],
191+ [
192+ 'devpod',
193+ IsAnalyticsOff.isEnvSet(
194+ await beneath.env.get('DEVPOD_WORKSPACE_UID'),
195+ ),
196+ ],
197+ [
198+ 'daytona',
199+ IsAnalyticsOff.isEnvSet(await beneath.env.get('DAYTONA_WS_ID')),
200+ ],
201+ [
202+ 'gcp-cloud-workstations',
203+ IsAnalyticsOff.isEnvTruthy(
204+ await beneath.env.get('GOOGLE_CLOUD_WORKSTATIONS'),
205+ ),
206+ ],
207+ [
208+ 'aws-cloud9',
209+ IsAnalyticsOff.isEnvSet(await beneath.env.get('C9_PID')),
210+ ],
211+ [
212+ 'aws-cloud9',
213+ IsAnalyticsOff.isEnvSet(await beneath.env.get('C9_USER')),
214+ ],
215+ ['replit', IsAnalyticsOff.isEnvSet(await beneath.env.get('REPL_ID'))],
216+ ['replit', IsAnalyticsOff.isEnvSet(await beneath.env.get('REPL_SLUG'))],
217+ [
218+ 'glitch',
219+ IsAnalyticsOff.isEnvSet(await beneath.env.get('PROJECT_DOMAIN')),
220+ ],
221+ ['vercel', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('VERCEL'))],
222+ [
223+ 'railway',
224+ IsAnalyticsOff.isEnvSet(
225+ await beneath.env.get('RAILWAY_ENVIRONMENT_NAME'),
226+ ),
227+ ],
228+ [
229+ 'railway',
230+ IsAnalyticsOff.isEnvSet(
231+ await beneath.env.get('RAILWAY_SERVICE_NAME'),
232+ ),
233+ ],
234+ ['render', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('RENDER'))],
235+ [
236+ 'netlify',
237+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('NETLIFY')),
238+ ],
239+ ['heroku', IsAnalyticsOff.isEnvSet(await beneath.env.get('DYNO'))],
240+ [
241+ 'fly.io',
242+ IsAnalyticsOff.isEnvSet(await beneath.env.get('FLY_APP_NAME')),
243+ ],
244+ [
245+ 'fly.io',
246+ IsAnalyticsOff.isEnvSet(await beneath.env.get('FLY_MACHINE_ID')),
247+ ],
248+ [
249+ 'cloudflare-pages',
250+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('CF_PAGES')),
251+ ],
252+ [
253+ 'deno-deploy',
254+ IsAnalyticsOff.isEnvSet(await beneath.env.get('DENO_DEPLOYMENT_ID')),
255+ ],
256+ [
257+ 'aws-lambda',
258+ IsAnalyticsOff.isEnvSet(
259+ await beneath.env.get('AWS_LAMBDA_FUNCTION_NAME'),
260+ ),
261+ ],
262+ [
263+ 'aws-fargate',
264+ (await beneath.env.get('AWS_EXECUTION_ENV')) === 'AWS_ECS_FARGATE',
265+ ],
266+ [
267+ 'aws-ecs',
268+ (await beneath.env.get('AWS_EXECUTION_ENV')) === 'AWS_ECS_EC2',
269+ ],
270+ [
271+ 'gcp-cloud-run',
272+ IsAnalyticsOff.isEnvSet(await beneath.env.get('K_SERVICE')),
273+ ],
274+ [
275+ 'gcp',
276+ IsAnalyticsOff.isEnvSet(
277+ await beneath.env.get('GOOGLE_CLOUD_PROJECT'),
278+ ),
279+ ],
280+ [
281+ 'azure-app-service',
282+ IsAnalyticsOff.isEnvSet(await beneath.env.get('WEBSITE_SITE_NAME')),
283+ ],
284+ [
285+ 'azure-app-service',
286+ IsAnalyticsOff.isEnvSet(await beneath.env.get('WEBSITE_SKU')),
287+ ],
288+ [
289+ 'azure-functions',
290+ IsAnalyticsOff.isEnvSet(
291+ await beneath.env.get('AZURE_FUNCTIONS_ENVIRONMENT'),
292+ ),
293+ ],
294+ [
295+ 'digitalocean-app-platform',
296+ IsAnalyticsOff.isEnvSet(await beneath.env.get('APP_URL')) &&
297+ String(await beneath.env.get('APP_URL')).includes(
298+ 'ondigitalocean.app',
299+ ),
300+ ],
301+ [
302+ 'huggingface-spaces',
303+ IsAnalyticsOff.isEnvSet(
304+ await beneath.env.get('SPACE_CREATOR_USER_ID'),
305+ ),
306+ ],
307+ [
308+ 'github-actions',
309+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('GITHUB_ACTIONS')),
310+ ],
311+ [
312+ 'gitlab-ci',
313+ IsAnalyticsOff.isEnvTruthy(await beneath.env.get('GITLAB_CI')),
314+ ],
315+ [
316+ 'circleci',
317+ IsAnalyticsOff.isEnvSet(await beneath.env.get('CIRCLECI')),
318+ ],
319+ [
320+ 'buildkite',
321+ IsAnalyticsOff.isEnvSet(await beneath.env.get('BUILDKITE')),
322+ ],
323+ ['ci', IsAnalyticsOff.isEnvTruthy(await beneath.env.get('CI'))],
324+ [
325+ 'kubernetes',
326+ IsAnalyticsOff.isEnvSet(
327+ await beneath.env.get('KUBERNETES_SERVICE_HOST'),
328+ ),
329+ ],
330+ ],
331+ configLocation: async () => ({
332+ configDir: await beneath.env.get('CLAUDE_CONFIG_DIR'),
333+ home: await beneath.env.get('HOME'),
334+ userProfile: await beneath.env.get('USERPROFILE'),
335+ }),
336+ cwd: () => beneath.session.cwd(),
337+ repo: () => beneath.session.repo(),
338+ read: path => beneath.fs.read(path),
339+ list: path => beneath.fs.list(path),
340+ exists: path => beneath.fs.exists(path),
341+ run: argv =>
342+ beneath.process.run(argv, { timeoutMs: Context.PROBE_TIMEOUT_MS }),
39343 fetch: (url, init) => beneath.http.fetch(url, init),
344+ after: (ms, fn) => beneath.clock.after(ms, fn),
345+ sleep: ms => beneath.clock.sleep(ms),
346+ debug: text => beneath.ui.log(text, { to: 'debug' }),
40347 })
41348
349+ const telemetry: EngineInterface['telemetry'] = sender.telemetry
350+
42351 return { ...beneath, telemetry }
43352 })
44353 }
@@ -1,0 +1,3 @@
1+export type * from './sender.js'
2+
3+export * as default from '.'
@@ -1,0 +1,14 @@
1+import type { Telemetry } from '../../types'
2+
3+/**
4+ * What `telemetryOf` builds: the `$.telemetry` noun, and the flush the
5+ * plugin's session.end hook calls to send what still waits.
6+ */
7+export type Sender = {
8+ telemetry: Telemetry
9+
10+ /**
11+ * Sends every queued row now, in one batch; resolves once it settled.
12+ */
13+ flush: () => Promise<void>
14+}
@@ -1,10 +1,25 @@
1-import type { HttpInit, HttpResponse, SessionAuthorization } from 'claude-code'
1+import type {
2+ FsEntry,
3+ HttpInit,
4+ HttpResponse,
5+ ProcessRunResult,
6+ SessionAuthorization,
7+ SessionRepo,
8+ Timer,
9+} from 'claude-code'
210
11+import type { ConfigLocation } from '../config-location'
12+import type Deployment from '../deployment'
313 import type { Environment } from '../environment'
14+import type { Facts } from '../facts'
15+import type { PolicyPins } from '../policy-pins'
416
517 /**
6- * What `telemetryOf` reaches on the nouns beneath: the session's authorize
7- * and reads, and one fetch. Each is a call on the plugin's own `$`.
18+ * What `telemetryOf` reaches on the nouns beneath, each a call on the
19+ * plugin's own `$`.
20+ *
21+ * The session, the environment, files, one process, the clock, one fetch
22+ * and the debug log.
823 */
924 export type TelemetryDeps = {
1025 /**
@@ -13,23 +28,94 @@
1328 authorize: () => Promise<SessionAuthorization>
1429
1530 /**
16- * The session's id, for the row.
31+ * The session's id, for the batch.
1732 */
1833 id: () => Promise<string>
1934
2035 /**
21- * The session's model, for the row.
36+ * The session's model, for the batch.
2237 */
2338 model: () => Promise<string>
2439
2540 /**
26- * Reads, at each call, what the row and the off switch need of the
27- * environment: the build's user type and every analytics-off variable.
41+ * Whether a person is at the prompt: what `session.start` said, else
42+ * whether the session draws on a terminal.
2843 */
44+ isInteractive: () => Promise<boolean>
45+
46+ /**
47+ * Reads, before each batch, the build's user type and every analytics-off
48+ * variable.
49+ */
2950 environment: () => Promise<Environment>
3051
3152 /**
32- * Posts the batch to the ingest with the credential handle.
53+ * Reads the managed policy's gateway pins.
3354 */
55+ policy: () => Promise<PolicyPins>
56+
57+ /**
58+ * Reads, once a session, the variables that describe it in a row.
59+ */
60+ facts: () => Promise<Facts>
61+
62+ /**
63+ * Reads, once a session, whether each deployment's variable holds.
64+ */
65+ deployment: () => Promise<readonly Deployment.DeploymentSignal[]>
66+
67+ /**
68+ * Reads where the CLI's global config file is.
69+ */
70+ configLocation: () => Promise<ConfigLocation>
71+
72+ /**
73+ * The directory the session runs in.
74+ */
75+ cwd: () => Promise<string>
76+
77+ /**
78+ * The repository the session runs in, or null.
79+ */
80+ repo: () => Promise<SessionRepo | null>
81+
82+ /**
83+ * Reads a file as text; rejects when it cannot be read.
84+ */
85+ read: (path: string) => Promise<string>
86+
87+ /**
88+ * Lists a directory; rejects when it cannot be listed.
89+ */
90+ list: (path: string) => Promise<readonly FsEntry[]>
91+
92+ /**
93+ * Whether a path exists.
94+ */
95+ exists: (path: string) => Promise<boolean>
96+
97+ /**
98+ * Runs one program to its end.
99+ */
100+ run: (argv: readonly string[]) => Promise<ProcessRunResult>
101+
102+ /**
103+ * Posts a batch to the ingest with the credential handle.
104+ */
34105 fetch: (url: string, init: HttpInit) => Promise<HttpResponse>
106+
107+ /**
108+ * Calls `fn` once after `ms` milliseconds.
109+ */
110+ after: (ms: number, fn: () => void) => Timer
111+
112+ /**
113+ * Resolves after `ms` milliseconds.
114+ */
115+ sleep: (ms: number) => Promise<void>
116+
117+ /**
118+ * Writes one line to the debug log.
119+ */
120+ debug: (text: string) => void
35121 }
@@ -1,74 +1,191 @@
1+import type { HttpResponse, Timer } from 'claude-code'
2+
13 import type { Telemetry } from '../../types'
4+import Batching from '../batching'
5+import Context from '../context'
26 import Entries from '../entries'
7+import type { Environment } from '../environment'
38 import { isAnalyticsOff } from '../is-analytics-off'
9+import type { Sender } from '../sender'
410 import type { TelemetryDeps } from '../telemetry-deps'
511
612 /**
7- * Builds `$.telemetry`: `log` and `mark` check the entry, read the environment
8- * and authorize afresh, build the first-party row and POST it to the ingest.
13+ * Builds `$.telemetry` and its flush: `log` and `mark` check the entry and
14+ * queue a row; a batch goes out on a timer, when full, or when flushed.
915 *
10- * One POST per call, rows one after another, nothing kept between them: a
11- * session that has moved to a third-party provider or a gateway, or turned
12- * analytics off, sends nothing more; no credential or a refusal rejects.
16+ * Each batch reads the switches afresh: analytics off, or unreadable, sends
17+ * nothing; no credential, or an ingest that refuses after one retry, drops
18+ * the batch. Each outcome is one debug line; the context is gathered once.
1319 *
1420 * @param deps the calls on the nouns beneath
15- * @returns the `$.telemetry` interface, `log` and `mark`
21+ * @returns the noun and its flush
1622 */
17-export function telemetryOf(deps: TelemetryDeps): Telemetry {
18- let queue: Promise<unknown> = Promise.resolve()
23+export function telemetryOf(deps: TelemetryDeps): Sender {
24+ let pending: Batching.PendingRow[] = []
25+ let timer: Timer | undefined
26+ let context: Promise<Context.Context> | undefined
27+ let priming: Promise<void> | undefined
28+ let sending: Promise<void> = Promise.resolve()
1929
20- async function post(
21- fields: Entries.Fields,
22- method: Entries.Method,
23- ): Promise<void> {
24- const environment = await deps.environment()
30+ function gathered(): Promise<Context.Context> {
31+ const gathering =
32+ context ??
33+ deps
34+ .isInteractive()
35+ .then(isInteractive => Context.contextOf(deps, isInteractive))
36+ .catch((error: unknown) => {
37+ context = undefined
38+ throw error
39+ })
2540
26- if (isAnalyticsOff(environment)) {
27- return
28- }
41+ context = gathering
2942
30- const body = Entries.batchOf(fields, {
31- sessionId: await deps.id(),
32- model: await deps.model(),
33- userType: environment.userType === 'ant' ? 'ant' : 'external',
34- })
43+ return gathering
44+ }
3545
36- const auth = await deps.authorize()
46+ async function sendingEnvironment(): Promise<Environment | undefined> {
47+ try {
48+ const [environment, policy] = await Promise.all([
49+ deps.environment(),
50+ deps.policy(),
51+ ])
3752
38- if (!auth) {
39- throw Entries.refusal(
40- 'this session has no first-party credential to authorize',
41- method,
53+ return isAnalyticsOff(environment, policy) ? undefined : environment
54+ } catch (error) {
55+ deps.debug(
56+ 'telemetry: the analytics switches could not be read, so nothing ' +
57+ `is sent (${Batching.messageOf(error)})`,
4258 )
59+
60+ return undefined
4361 }
62+ }
4463
45- const response = await deps.fetch(Entries.INGEST_URL, {
64+ async function primed(): Promise<void> {
65+ const environment = await sendingEnvironment()
66+
67+ if (environment) {
68+ await gathered()
69+ }
70+ }
71+
72+ const post = (body: string, auth: string): Promise<HttpResponse> =>
73+ deps.fetch(Entries.INGEST_URL, {
4674 method: 'POST',
4775 headers: {
4876 'Content-Type': 'application/json',
4977 'x-service-name': 'claude-code',
5078 },
51- auth: auth.handle,
79+ auth,
5280 body,
5381 })
5482
83+ async function postedWithRetry(
84+ body: string,
85+ auth: string,
86+ ): Promise<HttpResponse> {
87+ let first: HttpResponse | undefined
88+
89+ try {
90+ first = await post(body, auth)
91+ } catch {
92+ first = undefined
93+ }
94+
95+ const isSettled =
96+ first !== undefined && (first.ok || !Batching.isRetriable(first.status))
97+
98+ if (first !== undefined && isSettled) {
99+ return first
100+ }
101+
102+ await deps.sleep(Batching.RETRY_DELAY_MS)
103+
104+ return post(body, auth)
105+ }
106+
107+ async function send(rows: readonly Batching.PendingRow[]): Promise<void> {
108+ const environment = await sendingEnvironment()
109+
110+ if (!environment) {
111+ return
112+ }
113+
114+ const authorization = await deps.authorize()
115+
116+ if (!authorization) {
117+ throw new Error('this session has no first-party credential to authorize')
118+ }
119+
120+ const settled = await gathered()
121+
122+ const response = await postedWithRetry(
123+ Entries.batchOf(rows, {
124+ sessionId: await deps.id(),
125+ model: await deps.model(),
126+ userType: environment.userType === 'ant' ? 'ant' : 'external',
127+ isInteractive: await deps.isInteractive(),
128+ isClaudeAiAuth:
129+ authorization.kind === 'bearer' &&
130+ settled.identity.accountUuid !== undefined,
131+ context: settled,
132+ }),
133+ authorization.handle,
134+ )
135+
55136 if (!response.ok) {
56- throw Entries.refusal(`the ingest answered ${response.status}`, method)
137+ throw new Error(`the ingest answered ${response.status}`)
57138 }
139+
140+ deps.debug(`telemetry: sent ${rows.length} row(s)`)
58141 }
59142
60- function queued(
61- fields: Entries.Fields,
62- method: Entries.Method,
63- ): Promise<void> {
64- const turn = queue.then(() => post(fields, method))
65- queue = turn.catch(() => undefined)
143+ function flush(): Promise<void> {
144+ timer?.cancel()
145+ timer = undefined
66146
67- return turn
147+ const rows = pending
148+
149+ pending = []
150+
151+ if (rows.length === 0) {
152+ return sending
153+ }
154+
155+ sending = sending.then(() =>
156+ send(rows).catch((error: unknown) => {
157+ deps.debug(
158+ `telemetry: ${rows.length} row(s) not sent: ` +
159+ Batching.messageOf(error),
160+ )
161+ }),
162+ )
163+
164+ return sending
68165 }
69166
70- return {
71- log: async entry => queued(Entries.checkedFields(entry), 'log'),
72- mark: async entry => queued(Entries.checkedMark(entry), 'mark'),
167+ function queued(fields: Entries.Fields) {
168+ priming ??= primed().catch(() => undefined)
169+
170+ pending.push({
171+ fields,
172+ eventId: crypto.randomUUID(),
173+ loggedAt: new Date().toISOString(),
174+ })
175+
176+ if (pending.length >= Batching.BATCH_ROWS) {
177+ void flush()
178+
179+ return
180+ }
181+
182+ timer ??= deps.after(Batching.BATCH_WINDOW_MS, () => void flush())
73183 }
184+
185+ const telemetry: Telemetry = {
186+ log: async entry => queued(Entries.checkedFields(entry)),
187+ mark: async entry => queued(Entries.checkedMark(entry)),
188+ }
189+
190+ return { telemetry, flush }
74191 }
Dmods/telemetry/tests/fixtures/analytics-off-environments.ts+0 -18
@@ -1,18 +1,0 @@
1-/**
2- * One environment per switch that turns Claude Code's analytics off: the
3- * plugin sends nothing under any of them.
4- */
5-export const ANALYTICS_OFF_ENVIRONMENTS: readonly Readonly<
6- Record<string, string>
7->[] = [
8- { DISABLE_TELEMETRY: '0' },
9- { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' },
10- { DO_NOT_TRACK: 'true' },
11- { CLAUDE_CODE_USE_BEDROCK: '1' },
12- { CLAUDE_CODE_USE_VERTEX: 'yes' },
13- { CLAUDE_CODE_USE_FOUNDRY: 'on' },
14- { CLAUDE_CODE_USE_ANTHROPIC_AWS: 'TRUE' },
15- { CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: '1' },
16- { CLAUDE_CODE_USE_MANTLE: '1' },
17- { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://auth.example.invalid' },
18-]
Dmods/telemetry/tests/fixtures/base64-alphabet.ts+0 -5
@@ -1,5 +1,0 @@
1-/**
2- * The base64 digits in value order.
3- */
4-export const BASE64_ALPHABET =
5- 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
Dmods/telemetry/tests/fixtures/base64-decoded.ts+0 -23
@@ -1,23 +1,0 @@
1-import { BASE64_ALPHABET } from './base64-alphabet.js'
2-import { BASE64_DIGIT_BITS } from './base64-digit-bits.js'
3-
4-/**
5- * The text a base64 string encodes, byte for byte (the rows carry ASCII
6- * JSON), so a test reads a batch's `additional_metadata` back.
7- *
8- * @param encoded the base64 text
9- * @returns the decoded text
10- */
11-export const base64Decoded = (encoded: string): string =>
12- (
13- [...encoded.replace(/=+$/, '')]
14- .map(digit =>
15- BASE64_ALPHABET.indexOf(digit)
16- .toString(2)
17- .padStart(BASE64_DIGIT_BITS, '0'),
18- )
19- .join('')
20- .match(/[01]{8}/g) ?? []
21- )
22- .map(byte => String.fromCharCode(parseInt(byte, 2)))
23- .join('')
Dmods/telemetry/tests/fixtures/base64-digit-bits.ts+0 -4
@@ -1,4 +1,0 @@
1-/**
2- * How many bits one base64 digit carries.
3- */
4-export const BASE64_DIGIT_BITS = 6
Dmods/telemetry/tests/fixtures/batch-of.ts+0 -12
@@ -1,12 +1,0 @@
1-import type { Args } from 'claude-code'
2-
3-import type { IngestBatch } from './ingest-batch.js'
4-
5-/**
6- * The batch a post to the ingest carries, as it was sent.
7- *
8- * @param post the `http.fetch` the plugin made
9- * @returns the body, parsed
10- */
11-export const batchOf = (post: Args<'http.fetch'>): IngestBatch =>
12- JSON.parse(String(post.init?.body))
@@ -1,0 +1,4 @@
1+/**
2+ * Where the CLI's global config is under SENDING_ENV's HOME.
3+ */
4+export const CONFIG_PATH = '/Users/person/.claude.json'
@@ -1,0 +1,10 @@
1+import type { Telemetry } from '../../types'
2+
3+/**
4+ * A `telemetry` that takes every entry and does nothing with it: what the
5+ * replacing plugin tries to hand up in place of the real one.
6+ */
7+export const DEAF_TELEMETRY: Telemetry = {
8+ log: async () => undefined,
9+ mark: async () => undefined,
10+}
@@ -1,0 +1,5 @@
1+/**
2+ * What the global config records as this install's device id.
3+ */
4+export const DEVICE_ID =
5+ 'ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01ab01'
@@ -1,0 +1,10 @@
1+import type { SessionEndInput } from 'claude-code'
2+
3+/**
4+ * The session as it ends: the person left.
5+ */
6+export const ENDED: SessionEndInput = {
7+ reason: 'prompt_input_exit',
8+ sessionId: 'the-session',
9+ resume: { id: 'the-session' },
10+}
@@ -1,0 +1,20 @@
1+/**
2+ * One environment per switch that turns Claude Code's analytics off: the
3+ * plugin sends nothing under any of them.
4+ */
5+export const ANALYTICS_OFF_ENVIRONMENTS: readonly Readonly<
6+ Record<string, string>
7+>[] = [
8+ { DISABLE_TELEMETRY: '0' },
9+ { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' },
10+ { DO_NOT_TRACK: 'true' },
11+ { NODE_ENV: 'test' },
12+ { CLAUDE_CODE_USE_GATEWAY: '1' },
13+ { CLAUDE_CODE_USE_BEDROCK: '1' },
14+ { CLAUDE_CODE_USE_VERTEX: 'yes' },
15+ { CLAUDE_CODE_USE_FOUNDRY: 'on' },
16+ { CLAUDE_CODE_USE_ANTHROPIC_AWS: 'TRUE' },
17+ { CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: '1' },
18+ { CLAUDE_CODE_USE_MANTLE: '1' },
19+ { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://auth.example.invalid' },
20+]
@@ -1,0 +1,20 @@
1+import { SENDING_ENV } from './sending-env.js'
2+
3+/**
4+ * SENDING_ENV inside a GitHub Actions workflow running the Claude Code
5+ * action from a pull request.
6+ */
7+export const GITHUB_ACTION_ENV: Readonly<Record<string, string>> = {
8+ ...SENDING_ENV,
9+ CI: 'true',
10+ GITHUB_ACTIONS: 'true',
11+ GITHUB_EVENT_NAME: 'pull_request',
12+ RUNNER_OS: 'Linux',
13+ RUNNER_ENVIRONMENT: 'github-hosted',
14+ GITHUB_ACTION_PATH: '/runner/_actions/anthropics/claude-code-action/v1',
15+ GITHUB_ACTOR_ID: '11',
16+ GITHUB_REPOSITORY_ID: '22',
17+ GITHUB_REPOSITORY_OWNER_ID: '33',
18+ CLAUDE_CODE_ENTRYPOINT: 'claude-code-github-action',
19+ CLAUDE_CODE_ACTION: '1',
20+}
@@ -1,0 +1,11 @@
1+import { SENDING_ENV } from './sending-env.js'
2+
3+/**
4+ * SENDING_ENV on Bedrock under a host that manages the provider: the CLI's
5+ * analytics stay on there unless DISABLE_TELEMETRY says otherwise.
6+ */
7+export const HOST_MANAGED_BEDROCK_ENV: Readonly<Record<string, string>> = {
8+ ...SENDING_ENV,
9+ CLAUDE_CODE_USE_BEDROCK: '1',
10+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
11+}
@@ -1,0 +1,9 @@
1+export * from './analytics-off-environments.js'
2+export * from './github-action-env.js'
3+export * from './host-managed-bedrock-env.js'
4+export * from './linux-files.js'
5+export * from './linux-probed.js'
6+export * from './sending-env.js'
7+export * from './windows-env.js'
8+
9+export * as default from '.'
@@ -1,0 +1,9 @@
1+/**
2+ * The files an Ubuntu machine under WSL2 on EC2 holds where the plugin
3+ * reads the distribution, the kernel and the hypervisor.
4+ */
5+export const LINUX_FILES: Readonly<Record<string, string>> = {
6+ '/etc/os-release': 'NAME="Ubuntu"\nID=ubuntu\nVERSION_ID="24.04"\n',
7+ '/proc/version': 'Linux version 6.6.87.2-microsoft-standard-WSL2',
8+ '/sys/hypervisor/uuid': 'ec2e1916-9099-7caf-fd21-012345abcdef',
9+}
@@ -1,0 +1,6 @@
1+/**
2+ * What the one machine probe prints on that Linux machine: x86_64, npm and
3+ * node on the PATH.
4+ */
5+export const LINUX_PROBED =
6+ 'Linux\n6.6.87.2-microsoft-standard-WSL2\nx86_64\nnpm,\nnode,\n'
@@ -1,0 +1,11 @@
1+/**
2+ * The environment of an internal build's session on a Mac, in Ghostty under
3+ * zsh, signed in, its config under HOME: every switch clear, so rows go.
4+ */
5+export const SENDING_ENV: Readonly<Record<string, string>> = {
6+ USER_TYPE: 'ant',
7+ CLAUDE_CODE_ENTRYPOINT: 'cli',
8+ TERM_PROGRAM: 'ghostty',
9+ SHELL: '/bin/zsh',
10+ HOME: '/Users/person',
11+}
@@ -1,0 +1,12 @@
1+/**
2+ * The environment of an external build's session on Windows, in Windows
3+ * Terminal under cmd, its config under USERPROFILE.
4+ */
5+export const WINDOWS_ENV: Readonly<Record<string, string>> = {
6+ USER_TYPE: 'external',
7+ OS: 'Windows_NT',
8+ PROCESSOR_ARCHITECTURE: 'AMD64',
9+ COMSPEC: 'C:\\Windows\\System32\\cmd.exe',
10+ USERPROFILE: 'C:\\Users\\person',
11+ WT_SESSION: '1',
12+}
@@ -1,0 +1,42 @@
1+import { DEVICE_ID } from './device-id.js'
2+import { REMOTE_HASH } from './remote-hash.js'
3+
4+/**
5+ * The survey answer's row as SENDING_ENV's session sends it, complete: the
6+ * shape rowOf reads back, every column the plugin can fill filled.
7+ */
8+export const EXPECTED_ROW = {
9+ event_type: 'ClaudeCodeInternalEvent',
10+ hasEventId: true,
11+ hasTimestamp: true,
12+ event_name: 'tengu_plugin_survey_answered',
13+ device_id: DEVICE_ID,
14+ email: 'person@example.invalid',
15+ auth: { account_uuid: 'acc-1111-2222', organization_uuid: 'org-3333-4444' },
16+ session_id: 'the-session',
17+ model: 'the-model',
18+ user_type: 'ant',
19+ is_interactive: true,
20+ client_type: 'cli',
21+ entrypoint: 'cli',
22+ env: {
23+ platform: 'darwin',
24+ platform_raw: 'darwin',
25+ arch: 'arm64',
26+ terminal: 'ghostty',
27+ shell: 'zsh',
28+ package_managers: 'npm,pnpm',
29+ runtimes: 'bun,node',
30+ is_ci: false,
31+ is_claubbit: false,
32+ is_github_action: false,
33+ is_claude_code_action: false,
34+ is_claude_code_remote: false,
35+ is_local_agent_mode: false,
36+ is_conductor: false,
37+ deployment_environment: 'unknown-darwin',
38+ vcs: 'git',
39+ is_claude_ai_auth: true,
40+ },
41+ metadata: { rh: REMOTE_HASH, answer: 2, page: 'ready', seen: true },
42+}
@@ -1,33 +1,112 @@
1-import type { Args, HttpResponse, On, SessionAuthorization } from 'claude-code'
1+import type { Args, On } from 'claude-code'
2+import { mock } from 'claude-code/testing'
23
34 import { ACCEPTED } from './accepted.js'
45 import { BEARER } from './bearer.js'
6+import { CONFIG_PATH } from './config-path.js'
7+import { GLOBAL_CONFIG } from './global-config.js'
8+import { LISTING } from './listing.js'
9+import { PROBED } from './probed.js'
10+import { REPO } from './repo.js'
11+import type { SentSession } from './sent-session.js'
12+import type { SessionOptions } from './session-options.js'
513
614 /**
7- * Answers what the telemetry plugin reads of a session signed in first
8- * party, and keeps each post the ingest accepts.
15+ * Answers, beneath the plugins, everything the telemetry plugin reads of a
16+ * session signed in first party on a Mac in a git checkout, on a mock clock.
917 *
18+ * Keeps what the plugin did: each post, file read, program run and debug
19+ * line, as it does it.
20+ *
1021 * @param on the test's `on`
11- * @param authorization the credential the session holds
12- * @param answer what the ingest answers each post; accepted by default
13- * @returns each post, as it was made
22+ * @param options how this session differs from the plain one
23+ * @returns what the plugin did, and the clock
1424 */
1525 export function firstPartySession(
1626 on: On,
17- authorization: SessionAuthorization = BEARER,
18- answer: HttpResponse = ACCEPTED,
19-) {
27+ options: SessionOptions = {},
28+): SentSession {
2029 const posts: Args<'http.fetch'>[] = []
30+ const reads: string[] = []
31+ const runs: Args<'process.run'>[] = []
32+ const lines: string[] = []
33+ const clock = mock.clock(on)
2134
35+ const files: Readonly<Record<string, string>> = {
36+ [CONFIG_PATH]: GLOBAL_CONFIG,
37+ ...options.files,
38+ }
39+
40+ const answers = options.answers ?? [ACCEPTED]
41+ const authorizations = options.authorizations ?? [BEARER]
42+
43+ let authorizeCalls = 0
44+
45+ const isPolicyUnreadable = options.policy?.kind === 'unreadable'
46+
47+ on('session.start', ($, e) => ({ cwd: e.cwd }))
48+ on('session.end', ($, e) => ({ sessionId: e.sessionId }))
2249 on('session.id', () => ({ value: 'the-session' }))
2350 on('session.model', () => ({ value: 'the-model' }))
24- on('session.authorize', () => ({ value: authorization }))
51+ on('session.surfaces', () => ({ value: ['terminal'] }))
52+ on('session.cwd', () => ({ value: '/work' }))
53+ on('session.repo', () => ({ value: REPO }))
54+ on('fs.list', () => ({ value: LISTING }))
2555
56+ on('session.authorize', () => {
57+ authorizeCalls += 1
58+
59+ const answer =
60+ authorizations[Math.min(authorizeCalls, authorizations.length) - 1]
61+
62+ const isRefused = answer === 'refused'
63+
64+ return isRefused
65+ ? { deny: 'the credential store is locked' }
66+ : { value: answer === undefined ? BEARER : answer }
67+ })
68+
69+ on('settings.read', () =>
70+ isPolicyUnreadable
71+ ? { deny: 'the managed settings file is unreadable' }
72+ : { value: options.policy?.settings ?? {} },
73+ )
74+
75+ on('fs.read', ($, e) => {
76+ reads.push(e.path)
77+
78+ const text = files[e.path]
79+
80+ return text === undefined ? { deny: `ENOENT: ${e.path}` } : { value: text }
81+ })
82+
83+ on('fs.exists', ($, e) => ({
84+ value: (options.existing ?? []).includes(e.path),
85+ }))
86+
87+ const probed = { ...PROBED, stdout: options.probeOutput ?? PROBED.stdout }
88+
89+ on('process.run', ($, e) => {
90+ runs.push(e)
91+
92+ return { value: probed }
93+ })
94+
2695 on('http.fetch', ($, e) => {
2796 posts.push(e)
2897
29- return { value: answer }
98+ return {
99+ value: answers[Math.min(posts.length, answers.length) - 1] ?? ACCEPTED,
100+ }
30101 })
31102
32- return posts
103+ on('ui.log', ($, e) => {
104+ if (e.to === 'debug') {
105+ lines.push(e.text)
106+ }
107+
108+ return { value: undefined }
109+ })
110+
111+ return { posts, reads, runs, lines, clock }
33112 }
@@ -1,0 +1,14 @@
1+/**
2+ * A continuation as the gate's catch is handed it after a failure: going
3+ * on throws what beneath refused with.
4+ *
5+ * @param isCalled whether the failed hook had called next before it failed
6+ * @returns the continuation
7+ */
8+export const failingNext = (isCalled: boolean) =>
9+ Object.assign(
10+ () => {
11+ throw new Error('beneath refused the entry')
12+ },
13+ { called: isCalled, error: { kind: 'throw' as const, budget: 0 } },
14+ )
@@ -1,0 +1,4 @@
1+export * from './failing-next.js'
2+export * from './next-of.js'
3+
4+export * as default from '.'
@@ -1,0 +1,13 @@
1+import type { Tier } from 'claude-code'
2+
3+/**
4+ * A continuation as the gate is handed it: answers `served` when gone on
5+ * to, stamped with a caller in the tier given, or with no origin at all.
6+ *
7+ * @param tier the caller's tier; undefined for a call that names no origin
8+ * @returns the continuation
9+ */
10+export const nextOf = (tier: Tier | undefined) =>
11+ Object.assign(() => 'served', {
12+ origin: tier === undefined ? undefined : { plugin: 'caller', tier },
13+ })
@@ -1,0 +1,16 @@
1+import { DEVICE_ID } from './device-id.js'
2+
3+/**
4+ * The CLI's global config as the session's machine holds it: the install's
5+ * device id and the signed-in account, among keys the plugin never reads.
6+ */
7+export const GLOBAL_CONFIG = JSON.stringify({
8+ autoUpdates: true,
9+ userID: DEVICE_ID,
10+ oauthAccount: {
11+ accountUuid: 'acc-1111-2222',
12+ organizationUuid: 'org-3333-4444',
13+ emailAddress: 'person@example.invalid',
14+ },
15+ projects: { '/work': {} },
16+})
@@ -1,0 +1,6 @@
1+import type { Telemetry } from '../../types'
2+
3+/**
4+ * What the holding plugin keeps of the table it was handed: the noun.
5+ */
6+export type Held = { telemetry: Telemetry }
@@ -1,20 +1,32 @@
11 export * from './accepted.js'
2-export * from './analytics-off-environments.js'
3-export * from './base64-alphabet.js'
4-export * from './base64-decoded.js'
5-export * from './base64-digit-bits.js'
6-export * from './batch-of.js'
72 export * from './bearer.js'
3+export * from './config-path.js'
4+export * from './deaf-telemetry.js'
5+export * from './device-id.js'
6+export * from './ended.js'
7+export * from './environments'
8+export * from './expected-row.js'
89 export * from './first-party-session.js'
910 export * from './fullscreen.js'
10-export type * from './ingest-batch.js'
11-export type * from './ingest-event.js'
11+export * from './gate'
12+export * from './global-config.js'
13+export type * from './held.js'
14+export * from './listing.js'
1215 export * from './mark.js'
13-export * from './marking.js'
16+export * from './plugins'
17+export type * from './policy-option.js'
18+export * from './probed.js'
19+export * from './read-tool.js'
1420 export * from './record.js'
15-export * from './recording.js'
1621 export * from './refused.js'
17-export * from './row-of.js'
22+export * from './rejected.js'
23+export * from './remote-hash.js'
24+export * from './repo.js'
25+export type * from './sent-session.js'
26+export type * from './session-options.js'
27+export * from './started.js'
1828 export * from './survey-answer.js'
29+export * from './typed.js'
30+export * from './wire'
1931
2032 export * as default from '.'
Dmods/telemetry/tests/fixtures/ingest-batch.ts+0 -8
@@ -1,8 +1,0 @@
1-import type { IngestEvent } from './ingest-event.js'
2-
3-/**
4- * The body of one post to the ingest, parsed: its events.
5- */
6-export type IngestBatch = {
7- events: readonly IngestEvent[]
8-}
Dmods/telemetry/tests/fixtures/ingest-event.ts+0 -7
@@ -1,7 +1,0 @@
1-/**
2- * One event of a batch posted to the ingest: its type and its data fields.
3- */
4-export type IngestEvent = {
5- event_type: string
6- event_data: Record<string, unknown>
7-}
@@ -1,0 +1,8 @@
1+import type { FsEntry } from 'claude-code'
2+
3+/**
4+ * What the session's working directory lists: a git checkout.
5+ */
6+export const LISTING: FsEntry[] = [
7+ { name: '.git', kind: 'dir', size: 0, isLink: false },
8+]
Dmods/telemetry/tests/fixtures/marking.ts+0 -17
@@ -1,17 +1,0 @@
1-import type { Plugin } from 'claude-code/testing'
2-
3-/**
4- * A plugin whose `/mark <entry>` marks the entry through `$.telemetry`,
5- * answering "sent", or why the mark was refused.
6- */
7-export const marking: Plugin = {
8- name: 'marking',
9- register(on) {
10- on('command.run', { command: 'mark' }, ($, e) =>
11- $.telemetry.mark(JSON.parse(e.args)).then(
12- () => ({ text: 'sent' }),
13- (error: unknown) => ({ text: String(error) }),
14- ),
15- )
16- },
17-}
@@ -1,0 +1,29 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+import type { Held } from '../held.js'
4+
5+/**
6+ * A plugin a person installed that keeps the table its engine.create step
7+ * is handed, to call `telemetry.log` off it later; the scan refuses it.
8+ */
9+export const holding: Plugin = {
10+ name: 'holding',
11+ tier: 'user',
12+ register(on) {
13+ let held: Held | null = null
14+
15+ on('engine.create', async (_$, e, next) => {
16+ const beneath = await next(e)
17+
18+ held = beneath
19+
20+ return beneath
21+ })
22+
23+ on('command.run', { command: 'hold' }, async () => {
24+ await held?.telemetry.log({ event: 'held' })
25+
26+ return { text: 'served' }
27+ })
28+ },
29+}
@@ -1,0 +1,11 @@
1+export * from './holding.js'
2+export * from './managing.js'
3+export * from './marking.js'
4+export * from './meddling.js'
5+export * from './reaching.js'
6+export * from './recording.js'
7+export * from './replacing.js'
8+export * from './swallowing.js'
9+export * from './visiting.js'
10+
11+export * as default from '.'
@@ -1,0 +1,18 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin an administrator prepended whose `/manage <entry>` marks the
5+ * entry through `$.telemetry`, answering "queued", or why it was refused.
6+ */
7+export const managing: Plugin = {
8+ name: 'managing',
9+ tier: 'prepend',
10+ register(on) {
11+ on('command.run', { command: 'manage' }, ($, e) =>
12+ $.telemetry.mark(JSON.parse(e.args)).then(
13+ () => ({ text: 'queued' }),
14+ (error: unknown) => ({ text: String(error) }),
15+ ),
16+ )
17+ },
18+}
@@ -1,0 +1,18 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A built-in plugin whose `/mark <entry>` marks the entry through
5+ * `$.telemetry`, answering "queued", or why the mark was refused.
6+ */
7+export const marking: Plugin = {
8+ name: 'marking',
9+ tier: 'builtin',
10+ register(on) {
11+ on('command.run', { command: 'mark' }, ($, e) =>
12+ $.telemetry.mark(JSON.parse(e.args)).then(
13+ () => ({ text: 'queued' }),
14+ (error: unknown) => ({ text: String(error) }),
15+ ),
16+ )
17+ },
18+}
@@ -1,0 +1,20 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin a person installed that hooks `telemetry.log` above the gate to
5+ * rename every row, and logs one of its own through `/meddle`.
6+ */
7+export const meddling: Plugin = {
8+ name: 'meddling',
9+ tier: 'user',
10+ register(on) {
11+ on('telemetry.log', (_$, e, next) => next({ ...e, event: 'renamed' }))
12+
13+ on('command.run', { command: 'meddle' }, $ =>
14+ $.telemetry.log({ event: 'mine' }).then(
15+ () => ({ text: 'served' }),
16+ (error: unknown) => ({ text: String(error) }),
17+ ),
18+ )
19+ },
20+}
@@ -1,0 +1,49 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin a person installed that reaches for `$.telemetry` every way it
5+ * can; `/reach` says how the first went, the debug log the other two.
6+ *
7+ * On the table its engine.create step is handed, from a timer its
8+ * session.start sets, and from a tool.call hook.
9+ */
10+export const reaching: Plugin = {
11+ name: 'reaching',
12+ tier: 'user',
13+ register(on) {
14+ let during = 'not called'
15+
16+ on('engine.create', async (_$, e, next) => {
17+ const beneath = await next(e)
18+
19+ during = await beneath.telemetry
20+ .log({ event: 'during_create' })
21+ .then(() => 'served', String)
22+
23+ return beneath
24+ })
25+
26+ on('session.start', ($, e, next) => {
27+ $.clock.after(1, () => {
28+ void $.telemetry
29+ .log({ event: 'from_timer' })
30+ .then(() => 'served', String)
31+ .then(outcome => $.ui.log(`timer: ${outcome}`, { to: 'debug' }))
32+ })
33+
34+ return next(e)
35+ })
36+
37+ on('tool.call', { tool: 'Read' }, async ($, e, next) => {
38+ const outcome = await $.telemetry
39+ .log({ event: 'from_tool' })
40+ .then(() => 'served', String)
41+
42+ await $.ui.log(`tool: ${outcome}`, { to: 'debug' })
43+
44+ return next(e)
45+ })
46+
47+ on('command.run', { command: 'reach' }, () => ({ text: during }))
48+ },
49+}
@@ -1,0 +1,18 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A built-in plugin whose `/record <entry>` logs the entry through
5+ * `$.telemetry`, answering "queued", or why the row was refused.
6+ */
7+export const recording: Plugin = {
8+ name: 'recording',
9+ tier: 'builtin',
10+ register(on) {
11+ on('command.run', { command: 'record' }, ($, e) =>
12+ $.telemetry.log(JSON.parse(e.args)).then(
13+ () => ({ text: 'queued' }),
14+ (error: unknown) => ({ text: String(error) }),
15+ ),
16+ )
17+ },
18+}
@@ -1,0 +1,25 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+import { DEAF_TELEMETRY } from '../deaf-telemetry.js'
4+
5+/**
6+ * A plugin a person installed whose engine.create step hands up its own
7+ * `telemetry` in place of the one beneath, then logs through `/replace`.
8+ */
9+export const replacing: Plugin = {
10+ name: 'replacing',
11+ tier: 'user',
12+ register(on) {
13+ on('engine.create', async (_$, e, next) => ({
14+ ...(await next(e)),
15+ telemetry: DEAF_TELEMETRY,
16+ }))
17+
18+ on('command.run', { command: 'replace' }, $ =>
19+ $.telemetry.log({ event: 'mine' }).then(
20+ () => ({ text: 'served' }),
21+ (error: unknown) => ({ text: String(error) }),
22+ ),
23+ )
24+ },
25+}
@@ -1,0 +1,13 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin a person installed that answers `telemetry.log` above the gate
5+ * without going on beneath: the rows of everyone beneath it stop there.
6+ */
7+export const swallowing: Plugin = {
8+ name: 'swallowing',
9+ tier: 'user',
10+ register(on) {
11+ on('telemetry.log', () => ({ value: undefined }))
12+ },
13+}
@@ -1,0 +1,18 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin a person installed whose `/visit <entry>` logs the entry through
5+ * `$.telemetry`, answering "queued", or why the row was refused.
6+ */
7+export const visiting: Plugin = {
8+ name: 'visiting',
9+ tier: 'user',
10+ register(on) {
11+ on('command.run', { command: 'visit' }, ($, e) =>
12+ $.telemetry.log(JSON.parse(e.args)).then(
13+ () => ({ text: 'queued' }),
14+ (error: unknown) => ({ text: String(error) }),
15+ ),
16+ )
17+ },
18+}
@@ -1,0 +1,10 @@
1+/**
2+ * The managed policy a test's session reads: the settings' keys, or the
3+ * word `unreadable` when the settings cannot be read at all.
4+ */
5+export type PolicyOption =
6+ | {
7+ readonly kind: 'settings'
8+ readonly settings: Readonly<Record<string, unknown>>
9+ }
10+ | { readonly kind: 'unreadable' }
@@ -1,0 +1,11 @@
1+import type { ProcessRunResult } from 'claude-code'
2+
3+/**
4+ * What the one machine probe prints on the session's Mac: Darwin on arm64,
5+ * npm and pnpm on the PATH, bun and node too.
6+ */
7+export const PROBED: ProcessRunResult = {
8+ exitCode: 0,
9+ stdout: 'Darwin\n25.6.0\narm64\nnpm,pnpm,\nbun,node,\n',
10+ stderr: '',
11+}
@@ -1,0 +1,6 @@
1+import type { ToolCallArgs } from 'claude-code'
2+
3+/**
4+ * A Read the model asks for, for the reaching plugin's tool.call hook.
5+ */
6+export const READ_TOOL: ToolCallArgs = { tool: 'Read', file_path: '/work/a' }
Dmods/telemetry/tests/fixtures/recording.ts+0 -17
@@ -1,17 +1,0 @@
1-import type { Plugin } from 'claude-code/testing'
2-
3-/**
4- * A plugin whose `/record <entry>` logs the entry through `$.telemetry`,
5- * answering "sent", or why the row was refused.
6- */
7-export const recording: Plugin = {
8- name: 'recording',
9- register(on) {
10- on('command.run', { command: 'record' }, ($, e) =>
11- $.telemetry.log(JSON.parse(e.args)).then(
12- () => ({ text: 'sent' }),
13- (error: unknown) => ({ text: String(error) }),
14- ),
15- )
16- },
17-}
@@ -1,7 +1,7 @@
11 import type { HttpResponse } from 'claude-code'
22
33 /**
4- * The ingest's answer while it is down.
4+ * The ingest's answer while it is down: worth the one retry.
55 */
66 export const REFUSED: HttpResponse = {
77 status: 500,
@@ -1,0 +1,11 @@
1+import type { HttpResponse } from 'claude-code'
2+
3+/**
4+ * The ingest's answer to a batch it will never take: not retried.
5+ */
6+export const REJECTED: HttpResponse = {
7+ status: 400,
8+ ok: false,
9+ headers: {},
10+ text: '',
11+}
@@ -1,0 +1,5 @@
1+/**
2+ * The first sixteen hex digits of the SHA-256 of REPO's remote normalized
3+ * (`github.com/anthropics/claude-code`): what the rows carry as `rh`.
4+ */
5+export const REMOTE_HASH = 'd900cba35dcd6960'
@@ -1,0 +1,11 @@
1+import type { SessionRepo } from 'claude-code'
2+
3+/**
4+ * The repository the session runs in: a GitHub remote over ssh.
5+ */
6+export const REPO: SessionRepo = {
7+ root: '/work',
8+ remote: 'git@github.com:anthropics/claude-code.git',
9+ internal: false,
10+ name: null,
11+}
Dmods/telemetry/tests/fixtures/row-of.ts+0 -29
@@ -1,29 +1,0 @@
1-import type { Args } from 'claude-code'
2-
3-import { base64Decoded } from './base64-decoded.js'
4-import { batchOf } from './batch-of.js'
5-
6-/**
7- * The one row a post to the ingest carries, held steady for a test: its
8- * random id and timestamp dropped, `hasTimestamp` and `metadata` decoded.
9- *
10- * @param post the `http.fetch` the plugin made
11- * @returns the row's type, whether it was stamped, its fields and metadata
12- */
13-export function rowOf(post: Args<'http.fetch'>): unknown {
14- const [event] = batchOf(post).events
15-
16- const {
17- event_id: _eventId,
18- client_timestamp,
19- additional_metadata,
20- ...rest
21- } = event?.event_data ?? {}
22-
23- return {
24- event_type: event?.event_type,
25- hasTimestamp: typeof client_timestamp === 'string',
26- ...rest,
27- metadata: JSON.parse(base64Decoded(String(additional_metadata))),
28- }
29-}
@@ -1,0 +1,14 @@
1+import type { Args } from 'claude-code'
2+import type { MockClock } from 'claude-code/testing'
3+
4+/**
5+ * What a test reads back of its session: each post to the ingest, each file
6+ * read and program run the plugin made, each debug line, and the clock.
7+ */
8+export type SentSession = {
9+ readonly posts: Args<'http.fetch'>[]
10+ readonly reads: string[]
11+ readonly runs: Args<'process.run'>[]
12+ readonly lines: string[]
13+ readonly clock: MockClock
14+}
@@ -1,0 +1,19 @@
1+import type { HttpResponse, SessionAuthorization } from 'claude-code'
2+
3+import type { PolicyOption } from './policy-option.js'
4+
5+/**
6+ * How a test's session differs from the plain one.
7+ *
8+ * What `authorize` and the ingest answer in turn (`refused`: the store will
9+ * not open; the last repeats), the managed policy (`unreadable` when it
10+ * cannot be read), and the machine's files, markers and probe output.
11+ */
12+export type SessionOptions = {
13+ readonly authorizations?: readonly (SessionAuthorization | 'refused')[]
14+ readonly answers?: readonly HttpResponse[]
15+ readonly policy?: PolicyOption
16+ readonly files?: Readonly<Record<string, string>>
17+ readonly existing?: readonly string[]
18+ readonly probeOutput?: string
19+}
@@ -1,0 +1,10 @@
1+import type { SessionStartInput } from 'claude-code'
2+
3+/**
4+ * The session as it starts: a person at the prompt in the checkout.
5+ */
6+export const STARTED: SessionStartInput = {
7+ cwd: '/work',
8+ surface: 'terminal',
9+ isInteractive: true,
10+}
@@ -1,0 +1,17 @@
1+import type { CommandRunInput } from 'claude-code'
2+
3+import { FULLSCREEN } from './fullscreen.js'
4+
5+/**
6+ * A command typed at the prompt with an entry as its argument.
7+ *
8+ * @param command the command's name
9+ * @param entry what it carries
10+ * @returns the command as the person would run it
11+ */
12+export const typed = (command: string, entry: unknown): CommandRunInput => ({
13+ command,
14+ args: JSON.stringify(entry),
15+ origin: { kind: 'composer' },
16+ presentation: FULLSCREEN,
17+})
@@ -1,0 +1,5 @@
1+/**
2+ * The base64 digits in value order.
3+ */
4+export const BASE64_ALPHABET =
5+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
@@ -1,0 +1,23 @@
1+import { BASE64_ALPHABET } from './base64-alphabet.js'
2+import { BASE64_DIGIT_BITS } from './base64-digit-bits.js'
3+
4+/**
5+ * The text a base64 string encodes, byte for byte (the rows carry ASCII
6+ * JSON), so a test reads a batch's `additional_metadata` back.
7+ *
8+ * @param encoded the base64 text
9+ * @returns the decoded text
10+ */
11+export const base64Decoded = (encoded: string) =>
12+ (
13+ [...encoded.replace(/=+$/, '')]
14+ .map(digit =>
15+ BASE64_ALPHABET.indexOf(digit)
16+ .toString(2)
17+ .padStart(BASE64_DIGIT_BITS, '0'),
18+ )
19+ .join('')
20+ .match(/[01]{8}/g) ?? []
21+ )
22+ .map(byte => String.fromCharCode(parseInt(byte, 2)))
23+ .join('')
@@ -1,0 +1,4 @@
1+/**
2+ * How many bits one base64 digit carries.
3+ */
4+export const BASE64_DIGIT_BITS = 6
@@ -1,0 +1,12 @@
1+import type { Args } from 'claude-code'
2+
3+import type { IngestBatch } from './ingest-batch.js'
4+
5+/**
6+ * The batch a post to the ingest carries, as it was sent.
7+ *
8+ * @param post the `http.fetch` the plugin made
9+ * @returns the body, parsed
10+ */
11+export const batchOf = (post: Args<'http.fetch'>): IngestBatch =>
12+ JSON.parse(String(post.init?.body))
@@ -1,0 +1,12 @@
1+/**
2+ * The name a row was sent under, as rowsOf reads the row back; undefined
3+ * for a value that is no row.
4+ *
5+ * @param row one of rowsOf's rows
6+ * @returns the name the row was sent under
7+ */
8+export function eventNameOf(row: unknown): unknown {
9+ const isRow = typeof row === 'object' && row !== null && 'event_name' in row
10+
11+ return isRow ? row.event_name : undefined
12+}
@@ -1,0 +1,10 @@
1+export * from './base64-alphabet.js'
2+export * from './base64-decoded.js'
3+export * from './base64-digit-bits.js'
4+export * from './batch-of.js'
5+export * from './event-name-of.js'
6+export type * from './ingest-batch.js'
7+export type * from './ingest-event.js'
8+export * from './rows-of.js'
9+
10+export * as default from '.'
@@ -1,0 +1,8 @@
1+import type { IngestEvent } from './ingest-event.js'
2+
3+/**
4+ * The body of one post to the ingest, parsed: its events.
5+ */
6+export type IngestBatch = {
7+ events: readonly IngestEvent[]
8+}
@@ -1,0 +1,7 @@
1+/**
2+ * One event of a batch posted to the ingest: its type and its data fields.
3+ */
4+export type IngestEvent = {
5+ event_type: string
6+ event_data: Record<string, unknown>
7+}
@@ -1,0 +1,27 @@
1+import type { SentSession } from '../sent-session.js'
2+import { base64Decoded } from './base64-decoded.js'
3+import { batchOf } from './batch-of.js'
4+
5+/**
6+ * Every row the session's posts carried, in order, held steady for a test:
7+ * a row's random id and timestamp told as present, its metadata decoded.
8+ *
9+ * @param session what the plugin did
10+ * @returns each row's type and fields, with `metadata` decoded
11+ */
12+export const rowsOf = (session: SentSession): unknown[] =>
13+ session.posts.flatMap(post =>
14+ batchOf(post).events.map(event => {
15+ const { event_id, client_timestamp, additional_metadata, ...rest } =
16+ event.event_data
17+
18+ return {
19+ event_type: event.event_type,
20+ hasEventId:
21+ typeof event_id === 'string' && /^[0-9a-f-]{36}$/.test(event_id),
22+ hasTimestamp: typeof client_timestamp === 'string',
23+ ...rest,
24+ metadata: JSON.parse(base64Decoded(String(additional_metadata))),
25+ }
26+ }),
27+ )
@@ -1,0 +1,127 @@
1+import { describe, expect, mock, test, tier } from 'claude-code/testing'
2+
3+import Hooks from '../hooks'
4+import Fixtures from './fixtures'
5+
6+tier('builtin')
7+
8+const refusal = `HooksError: reaching: $.telemetry.log: ${Hooks.REFUSED.deny}`
9+
10+describe('gate', () => {
11+ test(
12+ 'the table an engine.create step is handed serves no one above either',
13+ { plugins: [Fixtures.recording, Fixtures.reaching] },
14+ async ($, on) => {
15+ mock.env(on, Fixtures.SENDING_ENV)
16+
17+ const session = Fixtures.firstPartySession(on)
18+
19+ on('tool.call', () => ({ result: 'read' }))
20+
21+ await $.session.start(Fixtures.STARTED)
22+ await session.clock.advance(1)
23+ await $.tool.call(Fixtures.READ_TOOL)
24+
25+ const during = (await $.command.run(Fixtures.typed('reach', {}))).text
26+
27+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
28+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
29+
30+ expect({
31+ isServedDuringCreate: during === 'served',
32+ later: session.lines.filter(line => !line.startsWith('telemetry: ')),
33+ rows: Fixtures.rowsOf(session).map(Fixtures.eventNameOf),
34+ }).toEqual({
35+ isServedDuringCreate: false,
36+ later: [`timer: ${refusal}`, `tool: ${refusal}`],
37+ rows: ['tengu_plugin_survey_answered'],
38+ })
39+ },
40+ )
41+
42+ test(
43+ 'a module that keeps the table to call later never loads',
44+ { plugins: [Fixtures.holding] },
45+ async ($, on) => {
46+ mock.env(on, Fixtures.SENDING_ENV)
47+
48+ const session = Fixtures.firstPartySession(on)
49+
50+ const held = await $.command
51+ .run(Fixtures.typed('hold', {}))
52+ .then(result => result.text, String)
53+
54+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
55+
56+ expect({
57+ isLoaded: !String(held).includes('did not load'),
58+ posts: session.posts,
59+ }).toEqual({ isLoaded: false, posts: [] })
60+ },
61+ )
62+
63+ test(
64+ 'a hook above may rename a row a built-in sends, never send its own',
65+ { plugins: [Fixtures.recording, Fixtures.meddling] },
66+ async ($, on) => {
67+ mock.env(on, Fixtures.SENDING_ENV)
68+
69+ const session = Fixtures.firstPartySession(on)
70+ const own = (await $.command.run(Fixtures.typed('meddle', {}))).text
71+
72+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
73+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
74+
75+ expect({
76+ own,
77+ rows: Fixtures.rowsOf(session).map(Fixtures.eventNameOf),
78+ }).toEqual({
79+ own: `HooksError: meddling: $.telemetry.log: ${Hooks.REFUSED.deny}`,
80+ rows: ['tengu_plugin_renamed'],
81+ })
82+ },
83+ )
84+
85+ test(
86+ 'a hook above that answers without going on sends nothing at all',
87+ { plugins: [Fixtures.recording, Fixtures.swallowing] },
88+ async ($, on) => {
89+ mock.env(on, Fixtures.SENDING_ENV)
90+
91+ const session = Fixtures.firstPartySession(on)
92+
93+ const answer = (
94+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
95+ ).text
96+
97+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
98+
99+ expect({ answer, posts: session.posts }).toEqual({
100+ answer: 'queued',
101+ posts: [],
102+ })
103+ },
104+ )
105+
106+ test(
107+ 'a step above cannot hand up its own telemetry in place of this one',
108+ { plugins: [Fixtures.recording, Fixtures.replacing] },
109+ async ($, on) => {
110+ mock.env(on, Fixtures.SENDING_ENV)
111+
112+ const session = Fixtures.firstPartySession(on)
113+ const own = (await $.command.run(Fixtures.typed('replace', {}))).text
114+
115+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
116+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
117+
118+ expect({
119+ own,
120+ rows: Fixtures.rowsOf(session).map(Fixtures.eventNameOf),
121+ }).toEqual({
122+ own: `HooksError: replacing: $.telemetry.log: ${Hooks.REFUSED.deny}`,
123+ rows: ['tengu_plugin_survey_answered'],
124+ })
125+ },
126+ )
127+})
Diff not shown: this commit changes too much to display at once.
Diff not shown: this commit changes too much to display at once.