Skip to content

Commit cbab6f4

Browse files
authored
telemetry: rows carry the engine's version, base version and build time from $.session.version() (#96487)
1 parent 6c6915e commit cbab6f4

14 files changed

Lines changed: 179 additions & 20 deletions

File tree

‎mods/telemetry/README.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@ entrypoint and interactivity, and an `env` block (platform and
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

‎mods/telemetry/hooks/context/environment-fields-of/environment-fields-of.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { tagsOf } from './tags-of'
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 @@ export function environmentFieldsOf(probe: Probe): EnvironmentFields {
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
}

‎mods/telemetry/hooks/context/environment-fields/environment-fields.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import type { GithubActionsFields } from './github-actions-fields'
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 @@ export type EnvironmentFields = {
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
}

‎mods/telemetry/hooks/context/probe-of/probe-of.ts‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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 @@ import { linuxFilesOf } from './linux-files-of'
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,14 +22,23 @@ export async function probeOf(
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+
)
38+
39+
return undefined
40+
}),
41+
])
3242

3343
const [identity, machine, vcs, remoteHash] = await Promise.all([
3444
Identity.identityOf(deps.read, location, environment.userType === 'ant'),
@@ -50,5 +60,6 @@ export async function probeOf(
5060
wslVersion: files.wslVersion,
5161
vcs,
5262
remoteHash,
63+
engine,
5364
}
5465
}

‎mods/telemetry/hooks/context/probe/probe.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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 @@ import type { Machine } from '../machine'
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 @@ export type Probe = {
1921
readonly wslVersion: string | undefined
2022
readonly vcs: string | undefined
2123
readonly remoteHash: string | undefined
24+
readonly engine: SessionVersion | undefined
2225
}

‎mods/telemetry/hooks/register.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ export function register(on: On) {
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),

‎mods/telemetry/hooks/telemetry-deps/telemetry-deps.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
ProcessRunResult,
66
SessionAuthorization,
77
SessionRepo,
8+
SessionVersion,
89
Timer,
910
} from 'claude-code'
1011

@@ -79,6 +80,12 @@ export type TelemetryDeps = {
7980
*/
8081
repo: () => Promise<SessionRepo | null>
8182

83+
/**
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+
8289
/**
8390
* Reads a file as text; rejects when it cannot be read.
8491
*/
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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+
}

‎mods/telemetry/tests/fixtures/expected-row.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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 @@ export const EXPECTED_ROW = {
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 },

‎mods/telemetry/tests/fixtures/first-party-session.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { mock } from 'claude-code/testing'
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 @@ import type { SessionOptions } from './session-options.js'
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 @@ export function firstPartySession(
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

0 commit comments

Comments
 (0)