telemetry: complete rows gathered through $, sent in batches, serving built-in plugins only (#95618)

This commit is contained in:
Alice T'Poteat
2026-09-20 01:02:49 -07:00
committed by GitHub
parent bf7d404e26
commit 4564326dca
217 changed files with 3677 additions and 339 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ source, published as it is built into the binary.
| --- | --- | --- |
| [`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 |
| [`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 |
| [`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 |
| [`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 |
| [`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 |
Each folder is a complete plugin: `.claude-plugin/plugin.json`, a
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "telemetry",
"version": "0.1.0",
"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.",
"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.",
"author": {
"name": "Anthropic"
},
+58 -29
View File
@@ -1,32 +1,55 @@
# telemetry
Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry` to
the engine interface every plugin above it is handed, built over the
`$.session` and `$.http` nouns beneath. `$.telemetry.log({ event, props })`
sends one event as one first-party row, `tengu_plugin_<event>`;
`$.telemetry.mark({ feature, kind, reason?, props? })` marks one use of a
feature as the CLI's own feature events do, `tengu_feature_<kind>` with a
`feature_name` and the mark's properties beside it. Each call is one POST to
the event-logging ingest with the session's own credential
(`$.session.authorize()`, resolved at each call), one attempt, nothing
batched; a session with no first-party credential, or an ingest that
refuses, rejects the caller's promise.
the engine interface every plugin above it is handed, built over the nouns
beneath, and a hook on its own two events serves the plugins built into
Claude Code alone: a call from a plugin a person installed or an
administrator listed is refused with a reason (the host stamps every call
with the plugin that raised it, `next.origin`, and the gate reads its tier).
`$.telemetry.log({ event, props })` queues one event as one first-party
row, `tengu_plugin_<event>`; `$.telemetry.mark({ feature, kind, reason?,
props? })` marks one use of a feature as the CLI's own feature events do,
`tengu_feature_<kind>` with a `feature_name` and the mark's properties
beside it. Both resolve once the row is queued. Rows go out in batches: one
POST to the event-logging ingest with the session's own credential
(`$.session.authorize()`, resolved for each batch) a few seconds after the
first row was queued, at once when a hundred wait, and when the session
ends; a batch the ingest refuses with a server error, a timeout or a rate
limit is tried once more. A session with no first-party credential, or an
ingest that still refuses, drops the batch; each outcome is one line in the
debug log.
Each row carries what the CLI's own rows carry, gathered through `$` once
a session: an event id, the install's device id and the signed-in account's
ids from the CLI's global config, the session's id, model, client type,
entrypoint and interactivity, and an `env` block (platform and
architecture from one `uname` probe, terminal, shell, package managers and
runtimes, CI and GitHub Actions, the remote container, the deployment, the
Linux distribution and kernel, WSL, the working directory's version
control), with the repository's remote hash beside the row's properties.
What the engine alone knows (its version and build time, its runtime's
version, the process's memory, the request's betas, the subscription tier,
the calling agent) is not on `$`, and those columns stay empty.
It sends nothing wherever the CLI's own analytics are off: under
`DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
`DO_NOT_TRACK`, on any third-party provider (Bedrock, Vertex, Foundry and
kin), and on a deployment with its own OAuth URL. Each is read through
`$.env` at every call, rows go one after another, and the credential is
authorized afresh right before each POST, so a session that has since moved
to a third-party provider or a cloud gateway sends nothing more. The row's
`user_type` is `ant` when `USER_TYPE` says so, else `external`.
`DO_NOT_TRACK`, in a test run, on any third-party provider (Bedrock,
Vertex, Foundry and kin) the host does not manage, on a cloud gateway
(the environment's switch or the managed policy's login pins), and on a
deployment with its own OAuth URL. Each is read through `$.env` and
`$.settings` before every batch, so a session that has since moved to a
third-party provider or a gateway sends nothing more; when the switches
cannot be read, nothing is sent either. The row's `user_type` is `ant`
when `USER_TYPE` says so, else `external`.
Nothing free-form reaches a row. An event name and every property key is a
snake_case token; a value is a finite number, a boolean, or a Choice (a
string named together with the list it is chosen from), under `log` and
`mark` alike; `mark` takes `ok`, `sad` or `bad`, with a `reason` required on
the last two and refused on the first. An entry that breaks a rule is
refused before anything is sent.
refused before anything is queued. Of the environment, a variable whose
value is a secret, or names a person or a host, is read for whether it is
set and nothing more; a shell is its basename from a closed list.
`hooks/register.ts` is the module; `types/index.d.ts` is the noun's contract,
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.
## What it hooks
`engine.create`: `{ ...await next(e), telemetry }`, so the noun is added and
nothing beneath is replaced.
nothing beneath is replaced. `telemetry.*`, the gate: a caller in the
built-in tier (or the engine) goes on, any other is refused, and a gate
that throws refuses too. `session.start`, to learn whether a person is at
the prompt; `session.end`, to send what still waits.
## What it calls on `$`
`session.authorize`, `session.id`, `session.model`, `http.fetch`, `env.get`
(the switches above and `USER_TYPE`, by literal name), each on the
interface the fold handed it.
`session.authorize`, `session.id`, `session.model`, `session.surfaces`,
`session.cwd`, `session.repo`, `settings.read`, `env.get` (the switches and
the describing variables, by literal name), `fs.read`, `fs.list`,
`fs.exists`, `process.run` (one `sh -c` of `uname` and `command -v`),
`clock.after`, `clock.sleep`, `http.fetch` and `ui.log` (to the debug log),
each on the interface the fold handed it.
## Where it runs
## Where it runs, whom it serves
This plugin is seated by the CLI itself, on internal builds whose own
analytics are on, and nowhere else: `session.authorize` exists only there,
and the rows it writes join tables only the CLI's own events reach. It is
not meant to be installed or loaded with `--plugin-dir`; the folder has a
manifest so it reads like every other plugin, not so it can stand alone. A
plugin that calls `$.telemetry` where this one is absent finds no such noun
and should treat that as "no analytics here".
This plugin is seated by the CLI itself, on every build whose own analytics
are on, and nowhere else; it serves the plugins bundled with the CLI and
refuses every other caller. It is not meant to be installed or loaded with
`--plugin-dir`; the folder has a manifest so it reads like every other
plugin, not so it can stand alone. A built-in that calls `$.telemetry`
where this one is absent finds no such noun and should treat that as "no
analytics here".
@@ -0,0 +1,5 @@
/**
* The most rows one batch carries: a queue this full goes out at once
* rather than on the timer.
*/
export const BATCH_ROWS = 100
@@ -0,0 +1,3 @@
export * from './batch-rows.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* How long a queued row waits for company before its batch goes out, in
* milliseconds; a session that ends sooner sends what waits then.
*/
export const BATCH_WINDOW_MS = 5000
@@ -0,0 +1,3 @@
export * from './batch-window-ms.js'
export * as default from '.'
+8
View File
@@ -0,0 +1,8 @@
export * from './batch-rows'
export * from './batch-window-ms'
export * from './is-retriable'
export * from './message-of'
export * from './pending-row'
export * from './retry-delay-ms'
export * as default from '.'
@@ -0,0 +1,6 @@
export * from './is-retriable.js'
export * from './status-request-timeout'
export * from './status-server-error'
export * from './status-too-many-requests'
export * as default from '.'
@@ -0,0 +1,15 @@
import { STATUS_REQUEST_TIMEOUT } from './status-request-timeout'
import { STATUS_SERVER_ERROR } from './status-server-error'
import { STATUS_TOO_MANY_REQUESTS } from './status-too-many-requests'
/**
* Whether an answer from the ingest is worth the one retry: a server error,
* a timeout or a rate limit; anything else it refused stays refused.
*
* @param status the HTTP status the ingest answered
* @returns true for 5xx, 408 and 429
*/
export const isRetriable = (status: number) =>
status >= STATUS_SERVER_ERROR ||
status === STATUS_REQUEST_TIMEOUT ||
status === STATUS_TOO_MANY_REQUESTS
@@ -0,0 +1,3 @@
export * from './status-request-timeout.js'
export * as default from '.'
@@ -0,0 +1,4 @@
/**
* HTTP 408: the ingest gave up waiting for the request; worth the one retry.
*/
export const STATUS_REQUEST_TIMEOUT = 408
@@ -0,0 +1,3 @@
export * from './status-server-error.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* HTTP 500, the first server-side status: it and everything above is the
* ingest's own failure and worth the one retry.
*/
export const STATUS_SERVER_ERROR = 500
@@ -0,0 +1,3 @@
export * from './status-too-many-requests.js'
export * as default from '.'
@@ -0,0 +1,4 @@
/**
* HTTP 429: the ingest is shedding load; worth the one retry after a pause.
*/
export const STATUS_TOO_MANY_REQUESTS = 429
@@ -0,0 +1,3 @@
export * from './message-of.js'
export * as default from '.'
@@ -0,0 +1,9 @@
/**
* What a batch's failure says, for the refusal each of its rows rejects
* with: the error's own message, or the value as text.
*
* @param error what sending the batch threw
* @returns the message
*/
export const messageOf = (error: unknown) =>
error instanceof Error ? error.message : String(error)
@@ -0,0 +1,3 @@
export type * from './pending-row.js'
export * as default from '.'
@@ -0,0 +1,11 @@
import type Entries from '../../entries'
/**
* One row waiting for its batch: the checked fields, and the id and time
* it was logged with.
*/
export type PendingRow = {
fields: Entries.Fields
eventId: string
loggedAt: string
}
@@ -0,0 +1,3 @@
export * from './retry-delay-ms.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* How long the one retry waits after a POST that failed or was answered with
* a status worth retrying, in milliseconds.
*/
export const RETRY_DELAY_MS = 500
@@ -0,0 +1,12 @@
/**
* Where the CLI's global config file is, read off the environment; read to
* find the file, never sent.
*
* The config directory when one is named, else the home directory (the
* profile directory on Windows).
*/
export type ConfigLocation = {
readonly configDir: string | undefined
readonly home: string | undefined
readonly userProfile: string | undefined
}
@@ -0,0 +1,3 @@
export type * from './config-location.js'
export * as default from '.'
@@ -0,0 +1,12 @@
/**
* The client types the CLI names by entrypoint alone: the SDKs, the VS Code
* and desktop hosts, and the local agent.
*/
export const CLIENT_TYPE_BY_ENTRYPOINT: Readonly<Record<string, string>> = {
'sdk-ts': 'sdk-typescript',
'sdk-py': 'sdk-python',
'sdk-cli': 'sdk-cli',
'claude-vscode': 'claude-vscode',
'local-agent': 'local-agent',
'claude-desktop': 'claude-desktop',
}
@@ -0,0 +1,3 @@
export * from './client-type-by-entrypoint.js'
export * as default from '.'
@@ -0,0 +1,26 @@
import type { Facts } from '../../facts'
import IsAnalyticsOff from '../../is-analytics-off'
import { CLIENT_TYPE_BY_ENTRYPOINT } from './client-type-by-entrypoint'
/**
* The row's `client_type` as the CLI decides it at start: a GitHub Action,
* an SDK or host by entrypoint, `remote` with an ingress token, else `cli`.
*
* @param facts the variables as read once for the session
* @returns the client type
*/
export function clientTypeOf(facts: Facts) {
const isRemote =
facts.entrypoint === 'remote' ||
facts.hasSessionAccessToken ||
facts.hasSessionIngressTokenFile ||
facts.hasWebsocketAuthFileDescriptor ||
IsAnalyticsOff.isEnvTruthy(facts.claudeCodeRemote)
const isGithubAction = IsAnalyticsOff.isEnvTruthy(facts.githubActions)
return isGithubAction
? 'github-action'
: (CLIENT_TYPE_BY_ENTRYPOINT[facts.entrypoint ?? ''] ??
(isRemote ? 'remote' : 'cli'))
}
@@ -0,0 +1,4 @@
export * from './client-type-by-entrypoint'
export * from './client-type-of.js'
export * as default from '.'
@@ -0,0 +1,30 @@
import type { TelemetryDeps } from '../telemetry-deps'
import type { Context } from './context'
import { environmentFieldsOf } from './environment-fields-of'
import { probeOf } from './probe-of'
import { sessionFieldsOf } from './session-fields-of'
/**
* Gathers and shapes what holds for the whole session, once: who the rows
* are from, the session's fields, its `env` block and the remote hash.
*
* Nothing in it is a secret or a path: every field is the closed value the
* CLI's own column holds.
*
* @param deps the calls on the nouns beneath
* @param isInteractive whether a person is at the prompt
* @returns the session's context for every batch after
*/
export async function contextOf(
deps: TelemetryDeps,
isInteractive: boolean,
): Promise<Context> {
const probe = await probeOf(deps, isInteractive)
return {
identity: probe.identity,
session: sessionFieldsOf(probe.facts),
environment: environmentFieldsOf(probe),
remoteHash: probe.remoteHash,
}
}
@@ -0,0 +1,17 @@
import type { EnvironmentFields } from '../environment-fields'
import type { Identity } from '../identity'
import type { SessionFields } from '../session-fields'
/**
* Everything a row carries that holds for the whole session, gathered once
* before the first batch.
*
* Who it is from, the session's constant fields, its `env` block, and the
* repository's remote hash for the metadata.
*/
export type Context = {
readonly identity: Identity
readonly session: SessionFields
readonly environment: EnvironmentFields
readonly remoteHash: string | undefined
}
@@ -0,0 +1,3 @@
export type * from './context.js'
export * as default from '.'
@@ -0,0 +1,13 @@
/**
* The row's `entrypoint` field: the variable as the CLI or its host set it
* when spelled like one (a short lowercase token), else `other`.
*
* @param variable CLAUDE_CODE_ENTRYPOINT as read; undefined stays so
* @returns the field, or undefined
*/
export function entrypointOf(variable: string | undefined) {
const isSpelled =
variable === undefined || /^[a-z][a-z0-9_-]{0,39}$/.test(variable)
return isSpelled ? variable : 'other'
}
@@ -0,0 +1,3 @@
export * from './entrypoint-of.js'
export * as default from '.'
@@ -0,0 +1,11 @@
/**
* The row's `github_action_ref`: what follows `claude-code-action/` in the
* action's path, as the CLI reads it; undefined when the path names none.
*
* @param actionPath GITHUB_ACTION_PATH as read
* @returns the ref, or undefined
*/
export const actionRefOf = (actionPath: string | undefined) =>
actionPath?.includes('claude-code-action/')
? actionPath.split('claude-code-action/')[1]
: undefined
@@ -0,0 +1,3 @@
export * from './action-ref-of.js'
export * as default from '.'
@@ -0,0 +1,60 @@
import Deployment from '../../deployment'
import IsAnalyticsOff from '../../is-analytics-off'
import type { EnvironmentFields } from '../environment-fields'
import type { Probe } from '../probe'
import { shellOf } from '../shell-of'
import { terminalOf } from '../terminal-of'
import { actionRefOf } from './action-ref-of'
import { githubActionsFieldsOf } from './github-actions-fields-of'
import { tagsOf } from './tags-of'
/**
* The rows' `env` block from what was probed, valued as the CLI's own is;
* the GitHub Actions fields only inside a workflow, the Linux ones on Linux.
*
* @param probe what was gathered for the session
* @returns the block
*/
export function environmentFieldsOf(probe: Probe): EnvironmentFields {
const { facts, machine, platform } = probe
const isGithubAction = IsAnalyticsOff.isEnvTruthy(facts.githubActions)
const isLinux = machine.platformRaw === 'linux'
return {
platform,
platformRaw: machine.platformRaw,
arch: machine.arch,
terminal: terminalOf(facts, platform, probe.isInteractive),
shell: shellOf(facts.shellPath),
packageManagers: machine.packageManagers,
runtimes: machine.runtimes,
isCi: IsAnalyticsOff.isEnvTruthy(facts.ci),
isClaubbit: IsAnalyticsOff.isEnvTruthy(facts.claubbit),
isGithubAction,
isClaudeCodeAction: IsAnalyticsOff.isEnvTruthy(facts.claudeCodeAction),
isClaudeCodeRemote: IsAnalyticsOff.isEnvTruthy(facts.claudeCodeRemote),
isLocalAgentMode: facts.entrypoint === 'local-agent',
isConductor: facts.bundleIdentifier === 'com.conductor.app',
deploymentEnvironment: Deployment.deploymentOf(probe.signals, platform),
remoteEnvironmentType: facts.remoteEnvironmentType,
claudeCodeContainerId: facts.containerId,
claudeCodeRemoteSessionId: facts.remoteSessionId,
tags: tagsOf(facts.tags),
githubEventName: isGithubAction ? facts.githubEventName : undefined,
githubActionsRunnerEnvironment: isGithubAction
? facts.runnerEnvironment
: undefined,
githubActionsRunnerOs: isGithubAction ? facts.runnerOs : undefined,
githubActionRef: isGithubAction
? actionRefOf(facts.githubActionPath)
: undefined,
githubActionsMetadata: isGithubAction
? githubActionsFieldsOf(facts)
: undefined,
wslVersion: probe.wslVersion,
linuxDistroId: probe.distro.id,
linuxDistroVersion: probe.distro.version,
linuxKernel: isLinux ? machine.kernel : undefined,
vcs: probe.vcs,
}
}
@@ -0,0 +1,26 @@
import type { Facts } from '../../../facts'
import type EnvironmentFields from '../../environment-fields'
/**
* The GitHub Actions ids for the row's `env` block; undefined when the
* workflow names none of the three.
*
* @param facts the variables as read once for the session
* @returns the ids, or undefined
*/
export function githubActionsFieldsOf(
facts: Facts,
): EnvironmentFields.GithubActionsFields | undefined {
const hasAny =
facts.githubActorId !== undefined ||
facts.githubRepositoryId !== undefined ||
facts.githubRepositoryOwnerId !== undefined
return hasAny
? {
actorId: facts.githubActorId,
repositoryId: facts.githubRepositoryId,
repositoryOwnerId: facts.githubRepositoryOwnerId,
}
: undefined
}
@@ -0,0 +1,3 @@
export * from './github-actions-fields-of.js'
export * as default from '.'
@@ -0,0 +1,6 @@
export * from './action-ref-of'
export * from './environment-fields-of.js'
export * from './github-actions-fields-of'
export * from './tags-of'
export * as default from '.'
@@ -0,0 +1,3 @@
export * from './tags-of.js'
export * as default from '.'
@@ -0,0 +1,15 @@
/**
* The row's `tags` field: the variable split on commas and trimmed, or
* undefined when it names none.
*
* @param variable CLAUDE_CODE_TAGS as read
* @returns the field, or undefined
*/
export function tagsOf(variable: string | undefined) {
const listed = (variable ?? '')
.split(',')
.map(tag => tag.trim())
.filter(Boolean)
return listed.length > 0 ? listed : undefined
}
@@ -0,0 +1,41 @@
import type { GithubActionsFields } from './github-actions-fields'
/**
* The `env` block every row of the session carries, as the CLI's own rows
* carry it, before its keys are spelled for the wire (Entries.wireOf).
*
* The machine, the terminal and shell, CI and GitHub Actions, the remote
* container, the deployment. `isClaudeAiAuth` follows the credential each
* batch is sent with and joins these then; an absent field is undefined.
*/
export type EnvironmentFields = {
readonly platform: string
readonly platformRaw: string
readonly arch: string
readonly terminal: string
readonly shell: string
readonly packageManagers: string
readonly runtimes: string
readonly isCi: boolean
readonly isClaubbit: boolean
readonly isGithubAction: boolean
readonly isClaudeCodeAction: boolean
readonly isClaudeCodeRemote: boolean
readonly isLocalAgentMode: boolean
readonly isConductor: boolean
readonly deploymentEnvironment: string
readonly remoteEnvironmentType: string | undefined
readonly claudeCodeContainerId: string | undefined
readonly claudeCodeRemoteSessionId: string | undefined
readonly tags: readonly string[] | undefined
readonly githubEventName: string | undefined
readonly githubActionsRunnerEnvironment: string | undefined
readonly githubActionsRunnerOs: string | undefined
readonly githubActionRef: string | undefined
readonly githubActionsMetadata: GithubActionsFields | undefined
readonly wslVersion: string | undefined
readonly linuxDistroId: string | undefined
readonly linuxDistroVersion: string | undefined
readonly linuxKernel: string | undefined
readonly vcs: string | undefined
}
@@ -0,0 +1,9 @@
/**
* The GitHub Actions ids a row's `env` block carries when the session runs
* in a workflow: the actor, the repository and its owner, by number.
*/
export type GithubActionsFields = {
readonly actorId: string | undefined
readonly repositoryId: string | undefined
readonly repositoryOwnerId: string | undefined
}
@@ -0,0 +1,3 @@
export type * from './github-actions-fields.js'
export * as default from '.'
@@ -0,0 +1,4 @@
export type * from './environment-fields.js'
export * from './github-actions-fields'
export * as default from '.'
@@ -0,0 +1,19 @@
import type { ConfigLocation } from '../../../config-location'
/**
* Where the CLI keeps its global config: `.claude.json` under the config
* directory when one is named, else under the home or profile directory.
*
* @param location the three variables as read
* @returns the file's path, or undefined when no directory is named
*/
export function globalConfigPath(location: ConfigLocation): string | undefined {
const directory = location.configDir || location.home || location.userProfile
const separator =
directory?.includes('\\') && !directory.includes('/') ? '\\' : '/'
return directory
? `${directory.replace(/[\\/]+$/, '')}${separator}.claude.json`
: undefined
}
@@ -0,0 +1,3 @@
export * from './global-config-path.js'
export * as default from '.'
@@ -0,0 +1,54 @@
import type { ConfigLocation } from '../../config-location'
import Entries from '../../entries'
import { globalConfigPath } from './global-config-path'
import type { Identity } from './identity'
import { plausibleUuid } from './plausible-uuid'
/**
* Reads the identity the CLI's global config records: the device id it
* minted for this install, and the account it is signed in to.
*
* A config that is missing, unreadable or not JSON yields an identity with
* nothing in it; the address is kept on an internal build alone.
*
* @param read reads a file as text
* @param location where the config is
* @param isInternal whether this is an internal build
* @returns the identity, each field undefined unless the config has it
*/
export async function identityOf(
read: (path: string) => Promise<string>,
location: ConfigLocation,
isInternal: boolean,
): Promise<Identity> {
const path = globalConfigPath(location)
let config: unknown
try {
config = path === undefined ? undefined : JSON.parse(await read(path))
} catch {
config = undefined
}
const record = Entries.isRecord(config) ? config : {}
const account = Entries.isRecord(record.oauthAccount)
? record.oauthAccount
: {}
const deviceId = record.userID
const email = account.emailAddress
const isDeviceId =
typeof deviceId === 'string' && /^[A-Za-z0-9_-]{8,128}$/.test(deviceId)
const isEmailKept = isInternal && typeof email === 'string' && email !== ''
return {
deviceId: isDeviceId ? deviceId : undefined,
accountUuid: plausibleUuid(account.accountUuid),
organizationUuid: plausibleUuid(account.organizationUuid),
email: isEmailKept ? email : undefined,
}
}
@@ -0,0 +1,13 @@
/**
* Who the rows are from, as the CLI's global config records it: the
* install's device id, and the signed-in account's ids and address.
*
* Each is undefined when the config has none or cannot be read; the address
* rides only on an internal build's rows, as the CLI's own does.
*/
export type Identity = {
readonly deviceId: string | undefined
readonly accountUuid: string | undefined
readonly organizationUuid: string | undefined
readonly email: string | undefined
}
@@ -0,0 +1,3 @@
export type * from './identity.js'
export * as default from '.'
@@ -0,0 +1,6 @@
export * from './global-config-path'
export * from './identity'
export * from './identity-of.js'
export * from './plausible-uuid'
export * as default from '.'
@@ -0,0 +1,5 @@
export * from './plausible-uuid.js'
export * from './uuid-length-floor'
export * from './uuid-length-limit'
export * as default from '.'
@@ -0,0 +1,18 @@
import { UUID_LENGTH_FLOOR } from './uuid-length-floor'
import { UUID_LENGTH_LIMIT } from './uuid-length-limit'
/**
* A value the config holds as an account or organization id, when it is a
* string long enough to be one; a number or a trivial string is dropped.
*
* @param value what the config holds under the key
* @returns the id, or undefined
*/
export function plausibleUuid(value: unknown): string | undefined {
const isPlausible =
typeof value === 'string' &&
value.length >= UUID_LENGTH_FLOOR &&
value.length <= UUID_LENGTH_LIMIT
return isPlausible ? value : undefined
}
@@ -0,0 +1,3 @@
export * from './uuid-length-floor.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* The shortest string taken for an account or organization id, the CLI's
* own floor: long enough to sweep trivial junk, no format assumed.
*/
export const UUID_LENGTH_FLOOR = 8
@@ -0,0 +1,3 @@
export * from './uuid-length-limit.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* The longest string taken for an account or organization id or a device
* id; anything longer is not an id and is dropped.
*/
export const UUID_LENGTH_LIMIT = 128
+21
View File
@@ -0,0 +1,21 @@
export * from './client-type-of'
export * from './context'
export * from './context-of.js'
export * from './entrypoint-of'
export * from './environment-fields'
export * from './environment-fields-of'
export * from './identity'
export * from './linux-distro-of'
export * from './machine'
export * from './probe'
export * from './probe-of'
export * from './remote-hash-of'
export * from './session-fields'
export * from './session-fields-of'
export * from './shell-of'
export * from './terminal-of'
export * from './vcs-of'
export * from './version-of'
export * from './wsl-version-of'
export * as default from '.'
@@ -0,0 +1,5 @@
export * from './linux-distro'
export * from './linux-distro-of.js'
export * from './os-release-value'
export * as default from '.'
@@ -0,0 +1,26 @@
import type { LinuxDistro } from './linux-distro'
import { osReleaseValue } from './os-release-value'
/**
* Reads the distribution's id and version off `/etc/os-release`, as the
* CLI's own rows do; a file that is missing or unreadable yields neither.
*
* @param readOsRelease reads `/etc/os-release` as text
* @returns the distribution
*/
export async function linuxDistroOf(
readOsRelease: () => Promise<string>,
): Promise<LinuxDistro> {
let content: string
try {
content = await readOsRelease()
} catch {
content = ''
}
return {
id: osReleaseValue(content, 'ID'),
version: osReleaseValue(content, 'VERSION_ID'),
}
}
@@ -0,0 +1,3 @@
export type * from './linux-distro.js'
export * as default from '.'
@@ -0,0 +1,8 @@
/**
* What `/etc/os-release` says of a Linux machine for the row: the
* distribution's `ID` and `VERSION_ID`, undefined when absent or unread.
*/
export type LinuxDistro = {
readonly id: string | undefined
readonly version: string | undefined
}
@@ -0,0 +1,3 @@
export * from './os-release-value.js'
export * as default from '.'
@@ -0,0 +1,14 @@
/**
* One key's value in an os-release file, unquoted; undefined when the file
* has no such line.
*
* @param content the file's text
* @param key the key
* @returns the value, or undefined
*/
export const osReleaseValue = (content: string, key: 'ID' | 'VERSION_ID') =>
content
.split('\n')
.map(line => /^(ID|VERSION_ID)=(.*)$/.exec(line))
.find(match => match?.[1] === key)?.[2]
?.replace(/^"|"$/g, '')
@@ -0,0 +1,18 @@
/**
* The architecture as the CLI's runtime names it, from what `uname -m` or
* Windows' PROCESSOR_ARCHITECTURE says: `x64`, `arm64`, `ia32`, else as is.
*
* @param machine the architecture as the system spells it
* @returns the runtime's spelling
*/
export function archOf(machine: string) {
const spelled = machine.trim().toLowerCase()
const named: readonly (readonly [boolean, string])[] = [
[spelled === 'x86_64' || spelled === 'amd64', 'x64'],
[spelled === 'aarch64' || spelled === 'arm64', 'arm64'],
[spelled === 'x86' || /^i[3-6]86$/.test(spelled), 'ia32'],
]
return named.find(([isIt]) => isIt)?.[1] ?? (spelled || 'unknown')
}
@@ -0,0 +1,3 @@
export * from './arch-of.js'
export * as default from '.'
@@ -0,0 +1,8 @@
export * from './arch-of'
export * from './machine'
export * from './machine-of.js'
export * from './platform-of'
export * from './probe-script'
export * from './probe-timeout-ms'
export * as default from '.'
@@ -0,0 +1,53 @@
import type { ProcessRunResult } from 'claude-code'
import type { Facts } from '../../facts'
import { archOf } from './arch-of'
import type { Machine } from './machine'
import { PROBE_SCRIPT } from './probe-script'
/**
* Probes the machine once: on Windows (the `OS` variable says so) from the
* environment alone, elsewhere with one `sh` run of PROBE_SCRIPT.
*
* A probe that fails or exits non-zero leaves the system unnamed
* (`unknown`) and the lists empty; nothing is retried.
*
* @param run runs one program to its end
* @param facts the variables as read, for the Windows answer
* @returns the machine as probed
*/
export async function machineOf(
run: (argv: readonly string[]) => Promise<ProcessRunResult>,
facts: Facts,
): Promise<Machine> {
if (facts.os === 'Windows_NT') {
return {
platformRaw: 'win32',
arch: archOf(facts.processorArchitecture ?? ''),
kernel: undefined,
packageManagers: '',
runtimes: '',
}
}
let result: ProcessRunResult | undefined
try {
result = await run(['sh', '-c', PROBE_SCRIPT])
} catch {
result = undefined
}
const [system = '', kernel = '', machine = '', managers = '', runtimes = ''] =
(result?.exitCode === 0 ? result.stdout.split('\n') : []).map(line =>
line.trim(),
)
return {
platformRaw: system.toLowerCase() || 'unknown',
arch: archOf(machine),
kernel: kernel || undefined,
packageManagers: managers.replace(/,$/, ''),
runtimes: runtimes.replace(/,$/, ''),
}
}
@@ -0,0 +1,3 @@
export type * from './machine.js'
export * as default from '.'
@@ -0,0 +1,15 @@
/**
* What one probe of the machine answers for the rows' `env` block: the
* system's own name, the architecture, the kernel, and what is on the PATH.
*
* `platformRaw` is the system's name lowercased (`darwin`, `linux`,
* `freebsd`, `win32`); the package managers and runtimes are comma-joined,
* an empty string for none.
*/
export type Machine = {
readonly platformRaw: string
readonly arch: string
readonly kernel: string | undefined
readonly packageManagers: string
readonly runtimes: string
}
@@ -0,0 +1,3 @@
export * from './platform-of.js'
export * as default from '.'
@@ -0,0 +1,17 @@
/**
* The row's `platform` as the CLI's own rows have it: the host platform the
* environment names when it is one of the three, else by the system's name.
*
* @param hostPlatform CLAUDE_CODE_HOST_PLATFORM, when set
* @param platformRaw the system's own name lowercased
* @returns `win32`, `darwin`, or `linux` for anything else
*/
export function platformOf(
hostPlatform: string | undefined,
platformRaw: string,
) {
const named = ['win32', 'darwin', 'linux'].find(one => one === hostPlatform)
const isOwnName = platformRaw === 'win32' || platformRaw === 'darwin'
return named ?? (isOwnName ? platformRaw : 'linux')
}
@@ -0,0 +1,3 @@
export * from './probe-script.js'
export * as default from '.'
@@ -0,0 +1,17 @@
/**
* The one shell script the plugin runs, once a session, anywhere but
* Windows; five lines come back.
*
* `uname -s`, `uname -r`, `uname -m`, then the package managers found of
* npm, yarn and pnpm comma-joined, then the runtimes found of bun, deno and
* node.
*/
export const PROBE_SCRIPT = [
'uname -s',
'uname -r',
'uname -m',
'for c in npm yarn pnpm; do command -v "$c" >/dev/null 2>&1 && ' +
'printf "%s," "$c"; done; echo',
'for c in bun deno node; do command -v "$c" >/dev/null 2>&1 && ' +
'printf "%s," "$c"; done; echo',
].join('\n')
@@ -0,0 +1,3 @@
export * from './probe-timeout-ms.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* How long the one machine probe may run before the host stops it, in
* milliseconds; a stopped probe leaves the machine unnamed.
*/
export const PROBE_TIMEOUT_MS = 5000
@@ -0,0 +1,5 @@
export * from './linux-files'
export * from './linux-files-of'
export * from './probe-of.js'
export * as default from '.'
@@ -0,0 +1,3 @@
export * from './linux-files-of.js'
export * as default from '.'
@@ -0,0 +1,40 @@
import type { TelemetryDeps } from '../../../telemetry-deps'
import { linuxDistroOf } from '../../linux-distro-of'
import { wslVersionOf } from '../../wsl-version-of'
import type { LinuxFiles } from '../linux-files'
/**
* Reads the four Linux files the rows draw on, on Linux alone; elsewhere,
* and for each file that cannot be read, the answer is empty.
*
* @param deps the calls on the nouns beneath
* @param isLinux whether the machine named itself Linux
* @returns what the files say
*/
export async function linuxFilesOf(
deps: TelemetryDeps,
isLinux: boolean,
): Promise<LinuxFiles> {
const [distro, wslVersion, isEc2, isDocker] = await Promise.all([
isLinux
? linuxDistroOf(() => deps.read('/etc/os-release'))
: { id: undefined, version: undefined },
isLinux ? wslVersionOf(() => deps.read('/proc/version')) : undefined,
isLinux
? deps.read('/sys/hypervisor/uuid').then(
uuid => uuid.startsWith('ec2'),
() => false,
)
: false,
isLinux ? deps.exists('/.dockerenv').catch(() => false) : false,
])
return {
distro,
wslVersion,
fileSignals: [
['aws-ec2', isEc2],
['docker', isDocker],
],
}
}
@@ -0,0 +1,3 @@
export type * from './linux-files.js'
export * as default from '.'
@@ -0,0 +1,12 @@
import type Deployment from '../../../deployment'
import type { LinuxDistro } from '../../linux-distro-of'
/**
* What the Linux files say for the rows: the distribution, the WSL release,
* and the two deployments whose signal is a file (EC2, Docker).
*/
export type LinuxFiles = {
readonly distro: LinuxDistro
readonly wslVersion: string | undefined
readonly fileSignals: readonly Deployment.DeploymentSignal[]
}
@@ -0,0 +1,54 @@
import type { TelemetryDeps } from '../../telemetry-deps'
import Identity from '../identity'
import Machine from '../machine'
import type { Probe } from '../probe'
import { remoteHashOf } from '../remote-hash-of'
import { vcsOf } from '../vcs-of'
import { linuxFilesOf } from './linux-files-of'
/**
* Gathers what holds for the whole session through the nouns beneath: the
* variables, the global config, one machine probe, the directory, the remote.
*
* Each read that fails leaves its part empty; the Linux files are read on
* Linux alone.
*
* @param deps the calls on the nouns beneath
* @param isInteractive whether a person is at the prompt
* @returns what was gathered, for context-of to shape
*/
export async function probeOf(
deps: TelemetryDeps,
isInteractive: boolean,
): Promise<Probe> {
const [facts, signals, location, environment, cwd, repo] = await Promise.all([
deps.facts(),
deps.deployment(),
deps.configLocation(),
deps.environment(),
deps.cwd().catch(() => undefined),
deps.repo().catch(() => null),
])
const [identity, machine, vcs, remoteHash] = await Promise.all([
Identity.identityOf(deps.read, location, environment.userType === 'ant'),
Machine.machineOf(deps.run, facts),
cwd === undefined ? undefined : vcsOf(deps.list, cwd, facts.hasP4Port),
remoteHashOf(repo?.remote),
])
const files = await linuxFilesOf(deps, machine.platformRaw === 'linux')
return {
facts,
signals: [...signals, ...files.fileSignals],
identity,
machine,
platform: Machine.platformOf(facts.hostPlatform, machine.platformRaw),
isInteractive,
distro: files.distro,
wslVersion: files.wslVersion,
vcs,
remoteHash,
}
}
@@ -0,0 +1,3 @@
export type * from './probe.js'
export * as default from '.'
@@ -0,0 +1,22 @@
import type Deployment from '../../deployment'
import type { Facts } from '../../facts'
import type { Identity } from '../identity'
import type { LinuxDistro } from '../linux-distro-of'
import type { Machine } from '../machine'
/**
* Everything gathered for the session's context before it is shaped: the
* variables, the identity, the machine, the Linux files and the directory.
*/
export type Probe = {
readonly facts: Facts
readonly signals: readonly Deployment.DeploymentSignal[]
readonly identity: Identity
readonly machine: Machine
readonly platform: string
readonly isInteractive: boolean
readonly distro: LinuxDistro
readonly wslVersion: string | undefined
readonly vcs: string | undefined
readonly remoteHash: string | undefined
}
@@ -0,0 +1,12 @@
import { HEX_RADIX } from './hex-radix'
/**
* A buffer's bytes as lowercase hex, two digits each.
*
* @param buffer the bytes
* @returns the hex text
*/
export const hexOf = (buffer: ArrayBuffer) =>
[...new Uint8Array(buffer)]
.map(byte => byte.toString(HEX_RADIX).padStart(2, '0'))
.join('')
@@ -0,0 +1,4 @@
/**
* Sixteen: the radix a byte is written in as two hex digits.
*/
export const HEX_RADIX = 16
@@ -0,0 +1,3 @@
export * from './hex-radix.js'
export * as default from '.'
@@ -0,0 +1,4 @@
export * from './hex-of.js'
export * from './hex-radix'
export * as default from '.'
@@ -0,0 +1,6 @@
export * from './hex-of'
export * from './normalized-remote-of'
export * from './remote-hash-length'
export * from './remote-hash-of.js'
export * as default from '.'
@@ -0,0 +1,3 @@
export * from './normalized-remote-of.js'
export * as default from '.'
@@ -0,0 +1,24 @@
/**
* A git remote URL as the CLI normalizes it before hashing: `host/path`
* lowercased, the user and a trailing `.git` dropped.
*
* The scp form (`git@host:owner/repo`) and the URL form (`https://` or
* `ssh://`) alike; undefined for anything else.
*
* @param remote the remote URL as `$.session.repo()` answers it
* @returns the normalized remote, or undefined
*/
export function normalizedRemoteOf(remote: string): string | undefined {
const trimmed = remote.trim()
const [, host, path] =
/^git@([^:/@]+):(.+?)(?:\.git)?$/.exec(trimmed) ??
/^(?:https?|ssh):\/\/(?:[^@/?#]*@)?([^/?#@]+)\/(.+?)(?:\.git)?$/.exec(
trimmed,
) ??
[]
const isRecognized = host !== undefined && path !== undefined
return isRecognized ? `${host}/${path}`.toLowerCase() : undefined
}
@@ -0,0 +1,3 @@
export * from './remote-hash-length.js'
export * as default from '.'
@@ -0,0 +1,5 @@
/**
* How many hex digits of the remote's SHA-256 a row carries as `rh`: the
* CLI's own sixteen, so the plugin's rows join where the CLI's do.
*/
export const REMOTE_HASH_LENGTH = 16
@@ -0,0 +1,24 @@
import { hexOf } from './hex-of'
import { normalizedRemoteOf } from './normalized-remote-of'
import { REMOTE_HASH_LENGTH } from './remote-hash-length'
/**
* The rows' `rh`: the first hex digits of the SHA-256 of the session's
* normalized origin remote, the key the CLI's own rows join a repository by.
*
* @param remote the origin remote as `$.session.repo()` answers it
* @returns the hash, or undefined outside a recognized remote
*/
export async function remoteHashOf(
remote: string | null | undefined,
): Promise<string | undefined> {
const normalized = remote ? normalizedRemoteOf(remote) : undefined
if (normalized === undefined) {
return undefined
}
return hexOf(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(normalized)),
).slice(0, REMOTE_HASH_LENGTH)
}
@@ -0,0 +1,3 @@
export * from './session-fields-of.js'
export * as default from '.'
@@ -0,0 +1,21 @@
import type { Facts } from '../../facts'
import { clientTypeOf } from '../client-type-of'
import { entrypointOf } from '../entrypoint-of'
import type { SessionFields } from '../session-fields'
import { versionOf } from '../version-of'
/**
* The session's constant row fields from the variables as read: the client,
* the entrypoint, the SDK's version and a benchmark run's ids.
*
* @param facts the variables as read once for the session
* @returns the fields
*/
export const sessionFieldsOf = (facts: Facts): SessionFields => ({
clientType: clientTypeOf(facts),
entrypoint: entrypointOf(facts.entrypoint),
agentSdkVersion: versionOf(facts.agentSdkVersion),
sweBenchRunId: facts.sweBenchRunId || undefined,
sweBenchInstanceId: facts.sweBenchInstanceId || undefined,
sweBenchTaskId: facts.sweBenchTaskId || undefined,
})
@@ -0,0 +1,3 @@
export type * from './session-fields.js'
export * as default from '.'
@@ -0,0 +1,15 @@
/**
* The row fields that are the session's for its whole life, before their
* keys are spelled for the wire.
*
* The client, the entrypoint, the SDK and a benchmark run's ids, each
* undefined when the environment names none.
*/
export type SessionFields = {
readonly clientType: string
readonly entrypoint: string | undefined
readonly agentSdkVersion: string | undefined
readonly sweBenchRunId: string | undefined
readonly sweBenchInstanceId: string | undefined
readonly sweBenchTaskId: string | undefined
}
@@ -0,0 +1,4 @@
export * from './known-shells'
export * from './shell-of.js'
export * as default from '.'
@@ -0,0 +1,3 @@
export * from './known-shells.js'
export * as default from '.'

Some files were not shown because too many files have changed in this diff Show More