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: rows carry the engine's version, base version and build time from $.session.version() (#96487)Browse files
Alice T'Poteat authored 28 days ago · GitHub committed 28 days ago parent 6c6915e · commit cbab6f4598e9
14 changed files+125 -20compared with 6c6915e
Mmods/telemetry/README.md
Mmods/telemetry/hooks/context/environment-fields/environment-fields.ts
Mmods/telemetry/hooks/context/environment-fields-of/environment-fields-of.ts
Mmods/telemetry/hooks/context/probe/probe.ts
Mmods/telemetry/hooks/context/probe-of/probe-of.ts
Mmods/telemetry/hooks/register.ts
Mmods/telemetry/hooks/telemetry-deps/telemetry-deps.ts
Amods/telemetry/tests/fixtures/engine-version.ts
Mmods/telemetry/tests/fixtures/expected-row.ts
Mmods/telemetry/tests/fixtures/first-party-session.ts
Mmods/telemetry/tests/fixtures/index.ts
Mmods/telemetry/tests/fixtures/session-options.ts
Mmods/telemetry/tests/register.test.ts
Mmods/types/claude-code.d.ts
@@ -26,10 +26,12 @@
2626 architecture from one `uname` probe, terminal, shell, package managers and
2727 runtimes, CI and GitHub Actions, the remote container, the deployment, the
2828 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.
29+control, and the engine's version, base version and build time from
30+`$.session.version()`, left empty on an engine that does not answer it),
31+with the repository's remote hash beside the row's properties. What the
32+engine alone knows (its runtime's version, the process's memory, the
33+request's betas, the subscription tier, the calling agent) is not on `$`,
34+and those columns stay empty.
3335
3436 It sends nothing wherever the CLI's own analytics are off: under
3537 `DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
@@ -5,8 +5,8 @@
55 * carry it, before its keys are spelled for the wire (Entries.wireOf).
66 *
77 * 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.
8+ * container, the deployment, the engine's version. `isClaudeAiAuth` follows
9+ * the credential each batch is sent with and joins these then.
1010 */
1111 export type EnvironmentFields = {
1212 readonly platform: string
@@ -38,4 +38,7 @@
3838 readonly linuxDistroVersion: string | undefined
3939 readonly linuxKernel: string | undefined
4040 readonly vcs: string | undefined
41+ readonly version: string | undefined
42+ readonly versionBase: string | undefined
43+ readonly buildTime: string | undefined
4144 }
@@ -12,6 +12,9 @@
1212 * The rows' `env` block from what was probed, valued as the CLI's own is;
1313 * the GitHub Actions fields only inside a workflow, the Linux ones on Linux.
1414 *
15+ * The engine's version, base version and build time are as it answered
16+ * them, absent where it did not.
17+ *
1518 * @param probe what was gathered for the session
1619 * @returns the block
1720 */
@@ -56,5 +59,8 @@
5659 linuxDistroVersion: probe.distro.version,
5760 linuxKernel: isLinux ? machine.kernel : undefined,
5861 vcs: probe.vcs,
62+ version: probe.engine?.version,
63+ versionBase: probe.engine?.base,
64+ buildTime: probe.engine?.builtAt,
5965 }
6066 }
@@ -1,3 +1,5 @@
1+import type { SessionVersion } from 'claude-code'
2+
13 import type Deployment from '../../deployment'
24 import type { Facts } from '../../facts'
35 import type { Identity } from '../identity'
@@ -6,7 +8,7 @@
68
79 /**
810 * Everything gathered for the session's context before it is shaped: the
9- * variables, the identity, the machine, the Linux files and the directory.
11+ * variables, the identity, the machine, the Linux files, the engine's version.
1012 */
1113 export type Probe = {
1214 readonly facts: Facts
@@ -19,4 +21,5 @@
1921 readonly wslVersion: string | undefined
2022 readonly vcs: string | undefined
2123 readonly remoteHash: string | undefined
24+ readonly engine: SessionVersion | undefined
2225 }
@@ -1,3 +1,4 @@
1+import Batching from '../../batching'
12 import type { TelemetryDeps } from '../../telemetry-deps'
23 import Identity from '../identity'
34 import Machine from '../machine'
@@ -8,10 +9,10 @@
89
910 /**
1011 * 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+ * variables, the global config, one machine probe, the directory, the engine.
1213 *
13- * Each read that fails leaves its part empty; the Linux files are read on
14- * Linux alone.
14+ * Each read that fails leaves its part empty, the engine's version saying so
15+ * once in the debug log; the Linux files are read on Linux alone.
1516 *
1617 * @param deps the calls on the nouns beneath
1718 * @param isInteractive whether a person is at the prompt
@@ -21,15 +22,24 @@
2122 deps: TelemetryDeps,
2223 isInteractive: boolean,
2324 ): 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- ])
25+ const [facts, signals, location, environment, cwd, repo, engine] =
26+ await Promise.all([
27+ deps.facts(),
28+ deps.deployment(),
29+ deps.configLocation(),
30+ deps.environment(),
31+ deps.cwd().catch(() => undefined),
32+ deps.repo().catch(() => null),
33+ deps.version().catch((error: unknown) => {
34+ deps.debug(
35+ "telemetry: the engine's version is not readable here, so the " +
36+ `version columns stay empty (${Batching.messageOf(error)})`,
37+ )
3238
39+ return undefined
40+ }),
41+ ])
42+
3343 const [identity, machine, vcs, remoteHash] = await Promise.all([
3444 Identity.identityOf(deps.read, location, environment.userType === 'ant'),
3545 Machine.machineOf(deps.run, facts),
@@ -50,5 +60,6 @@
5060 wslVersion: files.wslVersion,
5161 vcs,
5262 remoteHash,
63+ engine,
5364 }
5465 }
@@ -335,6 +335,7 @@
335335 }),
336336 cwd: () => beneath.session.cwd(),
337337 repo: () => beneath.session.repo(),
338+ version: async () => beneath.session.version(),
338339 read: path => beneath.fs.read(path),
339340 list: path => beneath.fs.list(path),
340341 exists: path => beneath.fs.exists(path),
@@ -5,6 +5,7 @@
55 ProcessRunResult,
66 SessionAuthorization,
77 SessionRepo,
8+ SessionVersion,
89 Timer,
910 } from 'claude-code'
1011
@@ -80,6 +81,12 @@
8081 repo: () => Promise<SessionRepo | null>
8182
8283 /**
84+ * The engine's version, its release and its build time; rejects on an
85+ * engine that does not answer it.
86+ */
87+ version: () => Promise<SessionVersion>
88+
89+ /**
8390 * Reads a file as text; rejects when it cannot be read.
8491 */
8592 read: (path: string) => Promise<string>
@@ -1,0 +1,11 @@
1+import type { SessionVersion } from 'claude-code'
2+
3+/**
4+ * What the engine answers `$.session.version()` in the plain session: a
5+ * development build, its release and its build time.
6+ */
7+export const ENGINE_VERSION: SessionVersion = {
8+ version: '2.1.300-dev.20260920.t101500.sha1a2b3c4',
9+ base: '2.1.300-dev',
10+ builtAt: '2026-09-20T10:15:00Z',
11+}
@@ -1,4 +1,5 @@
11 import { DEVICE_ID } from './device-id.js'
2+import { ENGINE_VERSION } from './engine-version.js'
23 import { REMOTE_HASH } from './remote-hash.js'
34
45 /**
@@ -36,6 +37,9 @@
3637 is_conductor: false,
3738 deployment_environment: 'unknown-darwin',
3839 vcs: 'git',
40+ version: ENGINE_VERSION.version,
41+ version_base: ENGINE_VERSION.base,
42+ build_time: ENGINE_VERSION.builtAt,
3943 is_claude_ai_auth: true,
4044 },
4145 metadata: { rh: REMOTE_HASH, answer: 2, page: 'ready', seen: true },
@@ -4,6 +4,7 @@
44 import { ACCEPTED } from './accepted.js'
55 import { BEARER } from './bearer.js'
66 import { CONFIG_PATH } from './config-path.js'
7+import { ENGINE_VERSION } from './engine-version.js'
78 import { GLOBAL_CONFIG } from './global-config.js'
89 import { LISTING } from './listing.js'
910 import { PROBED } from './probed.js'
@@ -16,7 +17,7 @@
1617 * session signed in first party on a Mac in a git checkout, on a mock clock.
1718 *
1819 * Keeps what the plugin did: each post, file read, program run and debug
19- * line, as it does it.
20+ * line, as it does it. The engine answers its version unless told not to.
2021 *
2122 * @param on the test's `on`
2223 * @param options how this session differs from the plain one
@@ -53,6 +54,12 @@
5354 on('session.repo', () => ({ value: REPO }))
5455 on('fs.list', () => ({ value: LISTING }))
5556
57+ const engineVersion = options.engineVersion ?? ENGINE_VERSION
58+
59+ if (engineVersion !== 'unanswered') {
60+ on('session.version', () => ({ value: engineVersion }))
61+ }
62+
5663 on('session.authorize', () => {
5764 authorizeCalls += 1
5865
@@ -4,6 +4,7 @@
44 export * from './deaf-telemetry.js'
55 export * from './device-id.js'
66 export * from './ended.js'
7+export * from './engine-version.js'
78 export * from './environments'
89 export * from './expected-row.js'
910 export * from './first-party-session.js'
@@ -1,4 +1,8 @@
1-import type { HttpResponse, SessionAuthorization } from 'claude-code'
1+import type {
2+ HttpResponse,
3+ SessionAuthorization,
4+ SessionVersion,
5+} from 'claude-code'
26
37 import type { PolicyOption } from './policy-option.js'
48
@@ -16,4 +20,10 @@
1620 readonly files?: Readonly<Record<string, string>>
1721 readonly existing?: readonly string[]
1822 readonly probeOutput?: string
23+
24+ /**
25+ * What `$.session.version()` answers; `unanswered` leaves nothing beneath
26+ * it, as under an engine without the read.
27+ */
28+ readonly engineVersion?: SessionVersion | 'unanswered'
1929 }
@@ -61,6 +61,45 @@
6161 )
6262
6363 test(
64+ 'an engine without the version read leaves those three columns empty',
65+ { plugins: [Fixtures.recording] },
66+ async ($, on) => {
67+ mock.env(on, Fixtures.SENDING_ENV)
68+
69+ const session = Fixtures.firstPartySession(on, {
70+ engineVersion: 'unanswered',
71+ })
72+
73+ await $.session.start(Fixtures.STARTED)
74+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
75+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
76+
77+ const { version, version_base, build_time, ...rest } =
78+ Fixtures.EXPECTED_ROW.env
79+
80+ expect([version, version_base, build_time]).toEqual([
81+ Fixtures.ENGINE_VERSION.version,
82+ Fixtures.ENGINE_VERSION.base,
83+ Fixtures.ENGINE_VERSION.builtAt,
84+ ])
85+
86+ expect(Fixtures.rowsOf(session)).toEqual([
87+ { ...Fixtures.EXPECTED_ROW, env: rest },
88+ ])
89+
90+ expect(session.lines).toEqual([
91+ expect.stringContaining(
92+ "telemetry: the engine's version is not readable here, so the " +
93+ 'version columns stay empty (',
94+ ),
95+ 'telemetry: sent 1 row(s)',
96+ ])
97+
98+ expect(session.lines[0]).toContain('session.version')
99+ },
100+ )
101+
102+ test(
64103 'a full queue goes out at once, before the window',
65104 { plugins: [Fixtures.recording] },
66105 async ($, on) => {
Large file, diff not shown.