SLOPSHOPPER

telemetry

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…

v0.1.0no licenseupdated 2026-09-20Prasanth595/claude-code/mods/telemetry
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (1 | import { KNOWN_SHELLS } from './known-shells' ^ error: No matching export in ".cache/src/prasanth595-claude-code-telemetry/hooks/context/shell-of/known-she
README

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 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.

What it hooks

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.

What it calls on $

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, whom it serves

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".

Source 91 files
hooks/register.ts 354 lines
1import 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}
354
hooks/context/index.ts 22 lines
1export * 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 '.'
22
hooks/gate/index.ts 8 lines
1export * from './caught'
2export * from './gate-next'
3export * from './refused'
4export * from './served'
5export * from './served-tiers'
6
7export * as default from '.'
8
hooks/is-analytics-off/index.ts 6 lines
1export * from './is-analytics-off.js'
2export * from './is-env-set'
3export * from './is-env-truthy'
4
5export * as default from '.'
6
hooks/sender/index.ts 4 lines
1export type * from './sender.js'
2
3export * as default from '.'
4
hooks/telemetry-of/index.ts 4 lines
1export * from './telemetry-of.js'
2
3export * as default from '.'
4
hooks/context/client-type-of/index.ts 5 lines
1export * from './client-type-by-entrypoint'
2export * from './client-type-of.js'
3
4export * as default from '.'
5
hooks/context/context/index.ts 4 lines
1export type * from './context.js'
2
3export * as default from '.'
4
hooks/context/context-of.ts 31 lines
1import 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}
31
hooks/context/entrypoint-of/index.ts 4 lines
1export * from './entrypoint-of.js'
2
3export * as default from '.'
4
hooks/context/environment-fields/index.ts 5 lines
1export type * from './environment-fields.js'
2export * from './github-actions-fields'
3
4export * as default from '.'
5
hooks/context/environment-fields-of/index.ts 7 lines
1export * 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