Skip to content

Commit 4564326

Browse files
authored
telemetry: complete rows gathered through $, sent in batches, serving built-in plugins only (#95618)
1 parent bf7d404 commit 4564326

217 files changed

Lines changed: 3664 additions & 326 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎mods/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ source, published as it is built into the binary.
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

‎mods/telemetry/.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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
},

‎mods/telemetry/README.md‎

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
21+
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.
1433

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 @@ noun and a test answering it all read.
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".
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './batch-rows.js'
2+
3+
export * as default from '.'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './batch-window-ms.js'
2+
3+
export * as default from '.'
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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 '.'
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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 '.'
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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

0 commit comments

Comments
 (0)