spacehub

surshar / claude-code

Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.

Built by Anthropic · github.com/anthropics

mods: the telemetry tests move next to the modBrowse files
poteat authored on Sep 13, 2026 parent 262e74d · commit 11ba4df3cb45
15 changed files+412 -28compared with 262e74d
Amods/telemetry/tests/fixtures/analytics-off-environments.ts
Amods/telemetry/tests/fixtures/base64-alphabet.ts
Amods/telemetry/tests/fixtures/base64-decoded.ts
Amods/telemetry/tests/fixtures/base64-digit-bits.ts
Mmods/telemetry/tests/fixtures/batch-of.ts
Mmods/telemetry/tests/fixtures/first-party-session.ts
Mmods/telemetry/tests/fixtures/index.ts
Amods/telemetry/tests/fixtures/ingest-batch.ts
Amods/telemetry/tests/fixtures/ingest-event.ts
Amods/telemetry/tests/fixtures/mark.ts
Amods/telemetry/tests/fixtures/marking.ts
Amods/telemetry/tests/fixtures/refused.ts
Amods/telemetry/tests/fixtures/row-of.ts
Mmods/telemetry/tests/fixtures/survey-answer.ts
Mmods/telemetry/tests/register.test.ts
@@ -1,0 +1,18 @@
1+/**
2+ * One environment per switch that turns Claude Code's analytics off: the
3+ * plugin sends nothing under any of them.
4+ */
5+export const ANALYTICS_OFF_ENVIRONMENTS: readonly Readonly<
6+ Record<string, string>
7+>[] = [
8+ { DISABLE_TELEMETRY: '0' },
9+ { CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' },
10+ { DO_NOT_TRACK: 'true' },
11+ { CLAUDE_CODE_USE_BEDROCK: '1' },
12+ { CLAUDE_CODE_USE_VERTEX: 'yes' },
13+ { CLAUDE_CODE_USE_FOUNDRY: 'on' },
14+ { CLAUDE_CODE_USE_ANTHROPIC_AWS: 'TRUE' },
15+ { CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD: '1' },
16+ { CLAUDE_CODE_USE_MANTLE: '1' },
17+ { CLAUDE_CODE_CUSTOM_OAUTH_URL: 'https://auth.example.invalid' },
18+]
@@ -1,0 +1,5 @@
1+/**
2+ * The base64 digits in value order.
3+ */
4+export const BASE64_ALPHABET =
5+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
@@ -1,0 +1,23 @@
1+import { BASE64_ALPHABET } from './base64-alphabet.js'
2+import { BASE64_DIGIT_BITS } from './base64-digit-bits.js'
3+
4+/**
5+ * The text a base64 string encodes, byte for byte (the rows carry ASCII
6+ * JSON), so a test reads a batch's `additional_metadata` back.
7+ *
8+ * @param encoded the base64 text
9+ * @returns the decoded text
10+ */
11+export const base64Decoded = (encoded: string): string =>
12+ (
13+ [...encoded.replace(/=+$/, '')]
14+ .map(digit =>
15+ BASE64_ALPHABET.indexOf(digit)
16+ .toString(2)
17+ .padStart(BASE64_DIGIT_BITS, '0'),
18+ )
19+ .join('')
20+ .match(/[01]{8}/g) ?? []
21+ )
22+ .map(byte => String.fromCharCode(parseInt(byte, 2)))
23+ .join('')
@@ -1,0 +1,4 @@
1+/**
2+ * How many bits one base64 digit carries.
3+ */
4+export const BASE64_DIGIT_BITS = 6
@@ -1,10 +1,12 @@
11 import type { Args } from 'claude-code'
22
3+import type { IngestBatch } from './ingest-batch.js'
4+
35 /**
46 * The batch a post to the ingest carries, as it was sent.
57 *
68 * @param post the `http.fetch` the plugin made
79 * @returns the body, parsed
810 */
9-export const batchOf = (post: Args<'http.fetch'>): unknown =>
11+export const batchOf = (post: Args<'http.fetch'>): IngestBatch =>
1012 JSON.parse(String(post.init?.body))
@@ -1,4 +1,4 @@
1-import type { Args, On, SessionAuthorization } from 'claude-code'
1+import type { Args, HttpResponse, On, SessionAuthorization } from 'claude-code'
22
33 import { ACCEPTED } from './accepted.js'
44 import { BEARER } from './bearer.js'
@@ -9,11 +9,13 @@
99 *
1010 * @param on the test's `on`
1111 * @param authorization the credential the session holds
12+ * @param answer what the ingest answers each post; accepted by default
1213 * @returns each post, as it was made
1314 */
1415 export function firstPartySession(
1516 on: On,
1617 authorization: SessionAuthorization = BEARER,
18+ answer: HttpResponse = ACCEPTED,
1719 ) {
1820 const posts: Args<'http.fetch'>[] = []
1921
@@ -24,7 +26,7 @@
2426 on('http.fetch', ($, e) => {
2527 posts.push(e)
2628
27- return { value: ACCEPTED }
29+ return { value: answer }
2830 })
2931
3032 return posts
@@ -1,9 +1,19 @@
11 export * from './accepted.js'
2+export * from './analytics-off-environments.js'
3+export * from './base64-alphabet.js'
4+export * from './base64-decoded.js'
5+export * from './base64-digit-bits.js'
26 export * from './batch-of.js'
37 export * from './bearer.js'
48 export * from './first-party-session.js'
9+export type * from './ingest-batch.js'
10+export type * from './ingest-event.js'
11+export * from './mark.js'
12+export * from './marking.js'
513 export * from './record.js'
614 export * from './recording.js'
15+export * from './refused.js'
16+export * from './row-of.js'
717 export * from './survey-answer.js'
818
919 export * as default from '.'
@@ -1,0 +1,8 @@
1+import type { IngestEvent } from './ingest-event.js'
2+
3+/**
4+ * The body of one post to the ingest, parsed: its events.
5+ */
6+export type IngestBatch = {
7+ events: readonly IngestEvent[]
8+}
@@ -1,0 +1,7 @@
1+/**
2+ * One event of a batch posted to the ingest: its type and its data fields.
3+ */
4+export type IngestEvent = {
5+ event_type: string
6+ event_data: Record<string, unknown>
7+}
@@ -1,0 +1,14 @@
1+import type { CommandRunInput } from 'claude-code'
2+
3+/**
4+ * The command that has the marking plugin mark an entry, typed as the
5+ * person would type it; an entry that is no object rides as it is.
6+ *
7+ * @param entry what to mark
8+ * @returns `/mark <entry>`
9+ */
10+export const mark = (entry: unknown): CommandRunInput => ({
11+ command: 'mark',
12+ args: JSON.stringify(entry),
13+ origin: { kind: 'composer' },
14+})
@@ -1,0 +1,17 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin whose `/mark <entry>` marks the entry through `$.telemetry`,
5+ * answering "sent", or why the mark was refused.
6+ */
7+export const marking: Plugin = {
8+ name: 'marking',
9+ register(on) {
10+ on('command.run', { command: 'mark' }, ($, e) =>
11+ $.telemetry.mark(JSON.parse(e.args)).then(
12+ () => ({ text: 'sent' }),
13+ (error: unknown) => ({ text: String(error) }),
14+ ),
15+ )
16+ },
17+}
@@ -1,0 +1,11 @@
1+import type { HttpResponse } from 'claude-code'
2+
3+/**
4+ * The ingest's answer while it is down.
5+ */
6+export const REFUSED: HttpResponse = {
7+ status: 500,
8+ ok: false,
9+ headers: {},
10+ text: '',
11+}
@@ -1,0 +1,29 @@
1+import type { Args } from 'claude-code'
2+
3+import { base64Decoded } from './base64-decoded.js'
4+import { batchOf } from './batch-of.js'
5+
6+/**
7+ * The one row a post to the ingest carries, held steady for a test: its
8+ * random id and timestamp dropped, `hasTimestamp` and `metadata` decoded.
9+ *
10+ * @param post the `http.fetch` the plugin made
11+ * @returns the row's type, whether it was stamped, its fields and metadata
12+ */
13+export function rowOf(post: Args<'http.fetch'>): unknown {
14+ const [event] = batchOf(post).events
15+
16+ const {
17+ event_id: _eventId,
18+ client_timestamp,
19+ additional_metadata,
20+ ...rest
21+ } = event?.event_data ?? {}
22+
23+ return {
24+ event_type: event?.event_type,
25+ hasTimestamp: typeof client_timestamp === 'string',
26+ ...rest,
27+ metadata: JSON.parse(base64Decoded(String(additional_metadata))),
28+ }
29+}
@@ -1,11 +1,16 @@
11 import type { TelemetryLogEntry } from '../../types'
22
33 /**
4- * A survey answered, as a plugin logs it.
4+ * A survey answered, as a plugin logs it: a number, a Choice and a
5+ * boolean.
56 *
67 * @returns the entry, fresh each call
78 */
89 export const surveyAnswer = (): TelemetryLogEntry => ({
910 event: 'survey_answered',
10- props: { answer: 2, seen: true },
11+ props: {
12+ answer: 2,
13+ page: { value: 'ready', of: ['ready', 'later'] },
14+ seen: true,
15+ },
1116 })
@@ -1,3 +1,4 @@
1+import type { Args } from 'claude-code'
12 import { describe, expect, mock, test, tier } from 'claude-code/testing'
23
34 import Fixtures from './fixtures'
@@ -17,22 +18,25 @@
1718 await $.command.run(Fixtures.record(Fixtures.surveyAnswer())),
1819 ).toEqual({ text: 'sent' })
1920
20- expect(posts.map(post => post.init)).toMatchObject([
21- { method: 'POST', auth: 'the-handle' },
21+ expect(
22+ posts.map(post => [post.url, post.init?.method, post.init?.auth]),
23+ ).toEqual([
24+ [
25+ 'https://api.anthropic.com/api/event_logging/v2/batch',
26+ 'POST',
27+ 'the-handle',
28+ ],
2229 ])
2330
24- expect(posts.map(Fixtures.batchOf)).toMatchObject([
31+ expect(posts.map(Fixtures.rowOf)).toEqual([
2532 {
26- events: [
27- {
28- event_data: {
29- event_name: 'tengu_plugin_survey_answered',
30- session_id: 'the-session',
31- model: 'the-model',
32- user_type: 'ant',
33- },
34- },
35- ],
33+ event_type: 'ClaudeCodeInternalEvent',
34+ hasTimestamp: true,
35+ event_name: 'tengu_plugin_survey_answered',
36+ session_id: 'the-session',
37+ model: 'the-model',
38+ user_type: 'ant',
39+ metadata: { answer: 2, page: 'ready', seen: true },
3640 },
3741 ])
3842 },
@@ -69,13 +73,50 @@
6973 )
7074
7175 test(
72- 'nothing is sent for a person who asked not to be tracked',
76+ 'nothing is sent where any switch has analytics off',
7377 { plugins: [Fixtures.recording] },
7478 async ($, on) => {
75- mock.env(on, { DO_NOT_TRACK: '1' })
79+ let environment: Readonly<Record<string, string>> = {}
7680
81+ on('env.get', ($, e) => ({ value: environment[e.name] }))
82+
7783 const posts = Fixtures.firstPartySession(on)
84+ const answers: (string | undefined)[] = []
7885
86+ for (const off of Fixtures.ANALYTICS_OFF_ENVIRONMENTS) {
87+ environment = { USER_TYPE: 'ant', ...off }
88+
89+ answers.push(
90+ (await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))).text,
91+ )
92+ }
93+
94+ const withheld = posts.length
95+
96+ environment = { USER_TYPE: 'ant' }
97+
98+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
99+
100+ expect(answers).toEqual(
101+ Fixtures.ANALYTICS_OFF_ENVIRONMENTS.map(() => 'sent'),
102+ )
103+
104+ expect(withheld, 'nothing posted while any switch was off').toBe(0)
105+ expect(posts, 'then one row once every switch is clear').toHaveLength(1)
106+ },
107+ )
108+
109+ test(
110+ 'a third-party provider the host manages sends nothing too',
111+ { plugins: [Fixtures.recording] },
112+ async ($, on) => {
113+ mock.env(on, {
114+ CLAUDE_CODE_USE_BEDROCK: '1',
115+ CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1',
116+ })
117+
118+ const posts = Fixtures.firstPartySession(on)
119+
79120 const { text } = await $.command.run(
80121 Fixtures.record(Fixtures.surveyAnswer()),
81122 )
@@ -85,15 +126,80 @@
85126 )
86127
87128 test(
88- 'nothing is sent on a third-party provider',
89- { plugins: [Fixtures.recording] },
129+ 'a $.telemetry.mark call posts the feature row by its own name',
130+ { plugins: [Fixtures.marking] },
90131 async ($, on) => {
91- mock.env(on, { CLAUDE_CODE_USE_BEDROCK: '1' })
132+ mock.env(on, { USER_TYPE: 'ant' })
92133
93134 const posts = Fixtures.firstPartySession(on)
135+ const answers: (string | undefined)[] = []
94136
95- await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
137+ for (const entry of [
138+ { feature: 'learn_page', kind: 'ok' },
139+ { feature: 'learn_page', kind: 'sad', reason: 'blocked' },
140+ { feature: 'suggest_learning', kind: 'bad', reason: 'api_error' },
141+ ]) {
142+ answers.push((await $.command.run(Fixtures.mark(entry))).text)
143+ }
96144
145+ expect(answers).toEqual(['sent', 'sent', 'sent'])
146+
147+ expect(
148+ posts.map(Fixtures.rowOf),
149+ 'the feature events by their own names, no plugin prefix',
150+ ).toMatchObject([
151+ {
152+ event_name: 'tengu_feature_ok',
153+ metadata: { feature_name: 'learn_page' },
154+ },
155+ {
156+ event_name: 'tengu_feature_sad',
157+ metadata: { feature_name: 'learn_page', error_code: 'blocked' },
158+ },
159+ {
160+ event_name: 'tengu_feature_bad',
161+ metadata: {
162+ feature_name: 'suggest_learning',
163+ error_code: 'api_error',
164+ },
165+ },
166+ ])
167+ },
168+ )
169+
170+ test(
171+ 'a mark with a bad kind or a wrong reason is refused, nothing sent',
172+ { plugins: [Fixtures.marking] },
173+ async ($, on) => {
174+ mock.env(on, { USER_TYPE: 'ant' })
175+
176+ const posts = Fixtures.firstPartySession(on)
177+
178+ const refusalFor = async (entry: unknown) =>
179+ (await $.command.run(Fixtures.mark(entry))).text
180+
181+ expect(
182+ await refusalFor({ feature: 'learn_page', kind: 'meh' }),
183+ ).toEndWith("$.telemetry.mark: kind: 'ok', 'sad' or 'bad'")
184+
185+ expect(
186+ await refusalFor({ feature: 'learn_page', kind: 'bad' }),
187+ ).toEndWith(
188+ '$.telemetry.mark: reason: a bad mark names why, a snake_case token',
189+ )
190+
191+ expect(
192+ await refusalFor({ feature: 'learn_page', kind: 'ok', reason: 'x' }),
193+ ).toEndWith('$.telemetry.mark: reason: an ok mark carries none')
194+
195+ expect(await refusalFor({ feature: 'Learn Page', kind: 'ok' })).toEndWith(
196+ '$.telemetry.mark: takes a feature name, a snake_case token',
197+ )
198+
199+ expect(await refusalFor('x')).toEndWith(
200+ '$.telemetry.mark: takes one entry, { feature, kind, reason?, props? }',
201+ )
202+
97203 expect(posts).toEqual([])
98204 },
99205 )
@@ -120,20 +226,143 @@
120226 )
121227
122228 test(
123- 'free text in a row is refused, nothing sent',
229+ "the ingest's refusal rejects the caller's promise",
124230 { plugins: [Fixtures.recording] },
125231 async ($, on) => {
126232 mock.env(on, {})
127233
234+ const posts = Fixtures.firstPartySession(
235+ on,
236+ Fixtures.BEARER,
237+ Fixtures.REFUSED,
238+ )
239+
240+ const { text } = await $.command.run(
241+ Fixtures.record(Fixtures.surveyAnswer()),
242+ )
243+
244+ expect(text).toEndWith('$.telemetry.log: the ingest answered 500')
245+ expect(posts).toHaveLength(1)
246+ },
247+ )
248+
249+ test(
250+ 'free text and a malformed entry are refused, nothing sent',
251+ { plugins: [Fixtures.recording] },
252+ async ($, on) => {
253+ mock.env(on, {})
254+
128255 const posts = Fixtures.firstPartySession(on)
129256
257+ const refusalFor = async (entry: unknown) =>
258+ (
259+ await $.command.run({
260+ ...Fixtures.record(Fixtures.surveyAnswer()),
261+ args: JSON.stringify(entry),
262+ })
263+ ).text
264+
265+ expect(
266+ await refusalFor({ event: 'x', props: { note: 'hello world' } }),
267+ ).toEndWith(
268+ '$.telemetry.log: props.note: free text is refused; a string is a ' +
269+ 'Choice, { value, of: [...] }',
270+ )
271+
272+ expect(
273+ await refusalFor({
274+ event: 'x',
275+ props: { page: { value: 'elsewhere', of: ['ready', 'later'] } },
276+ }),
277+ ).toEndWith(
278+ '$.telemetry.log: props.page.value: one of the members of `of`',
279+ )
280+
281+ expect(await refusalFor({ event: 'Survey' })).toEndWith(
282+ '$.telemetry.log: takes an event name, a snake_case token',
283+ )
284+
285+ expect(
286+ await refusalFor({ event: 'x', props: { 'a path': 1 } }),
287+ ).toEndWith('$.telemetry.log: props: every key is a snake_case token')
288+
289+ expect(await refusalFor('x')).toEndWith(
290+ '$.telemetry.log: takes one entry, { event, props? }',
291+ )
292+
293+ expect(posts).toEqual([])
294+ },
295+ )
296+
297+ test(
298+ 'a number that is not finite is refused, nothing sent',
299+ {
300+ plugins: [
301+ {
302+ name: 'counting',
303+ register(on) {
304+ on('command.run', { command: 'count' }, $ =>
305+ $.telemetry.log({ event: 'x', props: { n: Number.NaN } }).then(
306+ () => ({ text: 'sent' }),
307+ (error: unknown) => ({ text: String(error) }),
308+ ),
309+ )
310+ },
311+ },
312+ ],
313+ },
314+ async ($, on) => {
315+ mock.env(on, {})
316+
317+ const posts = Fixtures.firstPartySession(on)
318+
130319 const { text } = await $.command.run({
131- ...Fixtures.record(Fixtures.surveyAnswer()),
132- args: '{"event":"survey_answered","props":{"note":"hello world"}}',
320+ command: 'count',
321+ args: '',
322+ origin: { kind: 'composer' },
133323 })
134324
135- expect(text).toContain('props.note: free text is refused')
325+ expect(text).toEndWith('$.telemetry.log: props.n: a number is finite')
136326 expect(posts).toEqual([])
137327 },
138328 )
329+
330+ test(
331+ 'a failed authorize is not memoized; a retry sends',
332+ { plugins: [Fixtures.recording] },
333+ async ($, on) => {
334+ const posts: Args<'http.fetch'>[] = []
335+
336+ let authorizations = 0
337+
338+ mock.env(on, {})
339+ on('session.id', () => ({ value: 'the-session' }))
340+ on('session.model', () => ({ value: 'the-model' }))
341+
342+ on('session.authorize', () => {
343+ authorizations += 1
344+
345+ const isFirstAuthorization = authorizations === 1
346+
347+ return isFirstAuthorization
348+ ? { deny: 'transient' }
349+ : { value: Fixtures.BEARER }
350+ })
351+
352+ on('http.fetch', ($, e) => {
353+ posts.push(e)
354+
355+ return { value: Fixtures.ACCEPTED }
356+ })
357+
358+ const first = await $.command.run(Fixtures.record({ event: 'x' }))
359+ const postsAfterFirst = posts.length
360+ const second = await $.command.run(Fixtures.record({ event: 'x' }))
361+
362+ expect(first.text).toContain('transient')
363+ expect(postsAfterFirst).toBe(0)
364+ expect(second.text).toBe('sent')
365+ expect(posts).toHaveLength(1)
366+ },
367+ )
139368 })