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…

Plugin analytics as a plugin: one engine.create step adds $.telemetry to 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, 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 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 noun and a test answering it all read.
engine.create: { ...await next(e), telemetry }, so the noun is added and 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.
$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.
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".
hooks/register.ts 354 lines1import type { EngineInterface, On } from 'claude-code'
2
3import Context from './context'
4import Gate from './gate'
5import IsAnalyticsOff from './is-analytics-off'
6import type { Sender } from './sender'
7import { telemetryOf } from './telemetry-of'
8
9/**
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.
12 *
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.
16 *
17 * @param on the engine's registrar
18 */
19export function register(on: On) {
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) => {
40 const beneath = await next(e)
41
42 sender = telemetryOf({
43 authorize: () => beneath.session.authorize(),
44 id: () => beneath.session.id(),
45 model: () => beneath.session.model(),
46 isInteractive: async () =>
47 isInteractive ??
48 (await beneath.session.surfaces()).includes('terminal'),
49 environment: async () => ({
50 userType: await beneath.env.get('USER_TYPE'),
51 nodeEnv: await beneath.env.get('NODE_ENV'),
52 disableTelemetry: await beneath.env.get('DISABLE_TELEMETRY'),
53 disableNonessentialTraffic: await beneath.env.get(
54 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
55 ),
56 doNotTrack: await beneath.env.get('DO_NOT_TRACK'),
57 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 ),
62 useBedrock: await beneath.env.get('CLAUDE_CODE_USE_BEDROCK'),
63 useVertex: await beneath.env.get('CLAUDE_CODE_USE_VERTEX'),
64 useFoundry: await beneath.env.get('CLAUDE_CODE_USE_FOUNDRY'),
65 useAnthropicAws: await beneath.env.get('CLAUDE_CODE_USE_ANTHROPIC_AWS'),
66 useAnthropicGoogleCloud: await beneath.env.get(
67 'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
68 ),
69 useMantle: await beneath.env.get('CLAUDE_CODE_USE_MANTLE'),
70 }),
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 }),
343 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' }),
347 })
348
349 const telemetry: EngineInterface['telemetry'] = sender.telemetry
350
351 return { ...beneath, telemetry }
352 })
353}
354hooks/context/index.ts 22 lines1export * from './client-type-of'
2export * from './context'
3export * from './context-of.js'
4export * from './entrypoint-of'
5export * from './environment-fields'
6export * from './environment-fields-of'
7export * from './identity'
8export * from './linux-distro-of'
9export * from './machine'
10export * from './probe'
11export * from './probe-of'
12export * from './remote-hash-of'
13export * from './session-fields'
14export * from './session-fields-of'
15export * from './shell-of'
16export * from './terminal-of'
17export * from './vcs-of'
18export * from './version-of'
19export * from './wsl-version-of'
20
21export * as default from '.'
22hooks/gate/index.ts 8 lines1export * from './caught'
2export * from './gate-next'
3export * from './refused'
4export * from './served'
5export * from './served-tiers'
6
7export * as default from '.'
8hooks/is-analytics-off/index.ts 6 lines1export * from './is-analytics-off.js'
2export * from './is-env-set'
3export * from './is-env-truthy'
4
5export * as default from '.'
6hooks/sender/index.ts 4 lines1export type * from './sender.js'
2
3export * as default from '.'
4hooks/telemetry-of/index.ts 4 lines1export * from './telemetry-of.js'
2
3export * as default from '.'
4hooks/context/client-type-of/index.ts 5 lines1export * from './client-type-by-entrypoint'
2export * from './client-type-of.js'
3
4export * as default from '.'
5hooks/context/context/index.ts 4 lines1export type * from './context.js'
2
3export * as default from '.'
4hooks/context/context-of.ts 31 lines1import type { TelemetryDeps } from '../telemetry-deps'
2import type { Context } from './context'
3import { environmentFieldsOf } from './environment-fields-of'
4import { probeOf } from './probe-of'
5import { 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 */
18export 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}
31hooks/context/entrypoint-of/index.ts 4 lines1export * from './entrypoint-of.js'
2
3export * as default from '.'
4hooks/context/environment-fields/index.ts 5 lines1export type * from './environment-fields.js'
2export * from './github-actions-fields'
3
4export * as default from '.'
5hooks/context/environment-fields-of/index.ts 7 lines1export * from './action-ref-of'
2export * from './environment-fields-of.js'
3export * from './github-actions-fields-of'
4export * from './tags-of'
5
6export * as default from '.'
7