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

claude-code / mods / telemetry / tests / register.test.ts
701 lines · 19.9 KBRaw
1import { describe, expect, mock, test, tier } from 'claude-code/testing'
2
3import Hooks from '../hooks'
4import Fixtures from './fixtures'
5
6tier('builtin')
7
8const tiers = ['builtin', 'core', 'user', 'prepend', 'append'] as const
9
10describe('register', () => {
11 test(
12 'rows queued through $.telemetry.log go out as one complete batch',
13 { plugins: [Fixtures.recording] },
14 async ($, on) => {
15 mock.env(on, Fixtures.SENDING_ENV)
16
17 const session = Fixtures.firstPartySession(on)
18
19 await $.session.start(Fixtures.STARTED)
20
21 const first = await $.command.run(
22 Fixtures.record(Fixtures.surveyAnswer()),
23 )
24
25 await $.command.run(Fixtures.record({ event: 'again', props: { n: 1 } }))
26
27 expect(first).toEqual({ text: 'queued' })
28 expect(session.posts, 'nothing before the window passes').toEqual([])
29
30 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
31
32 expect(
33 session.posts.map(post => [
34 post.url,
35 post.init?.method,
36 post.init?.auth,
37 post.init?.headers,
38 ]),
39 ).toEqual([
40 [
41 'https://api.anthropic.com/api/event_logging/v2/batch',
42 'POST',
43 'the-handle',
44 {
45 'Content-Type': 'application/json',
46 'x-service-name': 'claude-code',
47 },
48 ],
49 ])
50
51 expect(Fixtures.rowsOf(session)).toHaveLength(2)
52 expect(Fixtures.rowsOf(session)[0]).toEqual(Fixtures.EXPECTED_ROW)
53
54 expect(Fixtures.rowsOf(session)[1]).toMatchObject({
55 event_name: 'tengu_plugin_again',
56 metadata: { rh: Fixtures.REMOTE_HASH, n: 1 },
57 })
58
59 expect(session.lines).toEqual(['telemetry: sent 2 row(s)'])
60 },
61 )
62
63 test(
64 'a full queue goes out at once, before the window',
65 { plugins: [Fixtures.recording] },
66 async ($, on) => {
67 mock.env(on, Fixtures.SENDING_ENV)
68
69 const session = Fixtures.firstPartySession(on)
70
71 for (let row = 0; row < Hooks.BATCH_ROWS; row++) {
72 await $.command.run(Fixtures.record({ event: 'x', props: { row } }))
73 }
74
75 await session.clock.settle()
76
77 expect(session.posts).toHaveLength(1)
78 expect(Fixtures.rowsOf(session)).toHaveLength(Hooks.BATCH_ROWS)
79 },
80 )
81
82 test(
83 'the end of the session sends what still waits',
84 { plugins: [Fixtures.recording] },
85 async ($, on) => {
86 mock.env(on, Fixtures.SENDING_ENV)
87
88 const session = Fixtures.firstPartySession(on)
89
90 await $.session.start(Fixtures.STARTED)
91 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
92 await $.session.end(Fixtures.ENDED)
93
94 expect(Fixtures.rowsOf(session)).toEqual([Fixtures.EXPECTED_ROW])
95 },
96 )
97
98 test(
99 'a tengu_ name is kept; an external build sends no address',
100 { plugins: [Fixtures.recording] },
101 async ($, on) => {
102 mock.env(on, { ...Fixtures.SENDING_ENV, USER_TYPE: 'external' })
103
104 const session = Fixtures.firstPartySession(on)
105
106 await $.command.run(
107 Fixtures.record({
108 ...Fixtures.surveyAnswer(),
109 event: 'tengu_repl_diff_panel_shown',
110 }),
111 )
112
113 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
114
115 expect(Fixtures.rowsOf(session)[0]).toMatchObject({
116 event_name: 'tengu_repl_diff_panel_shown',
117 user_type: 'external',
118 device_id: Fixtures.DEVICE_ID,
119 })
120
121 expect(Fixtures.rowsOf(session)[0]).not.toHaveProperty('email')
122 },
123 )
124
125 test(
126 'nothing is sent where any switch has analytics off',
127 { plugins: [Fixtures.recording] },
128 async ($, on) => {
129 let environment: Readonly<Record<string, string>> = {}
130
131 on('env.get', ($, e) => ({ value: environment[e.name] }))
132
133 const session = Fixtures.firstPartySession(on)
134 const answers: (string | undefined)[] = []
135
136 for (const off of Fixtures.ANALYTICS_OFF_ENVIRONMENTS) {
137 environment = { ...Fixtures.SENDING_ENV, ...off }
138
139 answers.push(
140 (await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))).text,
141 )
142
143 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
144 }
145
146 const withheld = session.posts.length
147
148 environment = Fixtures.SENDING_ENV
149
150 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
151 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
152
153 expect(answers).toEqual(
154 Fixtures.ANALYTICS_OFF_ENVIRONMENTS.map(() => 'queued'),
155 )
156
157 expect(withheld, 'nothing posted while any switch was off').toBe(0)
158
159 expect(
160 session.posts,
161 'one batch once every switch is clear',
162 ).toHaveLength(1)
163 },
164 )
165
166 test(
167 'a gateway the managed policy pins sends nothing',
168 { plugins: [Fixtures.recording] },
169 async ($, on) => {
170 mock.env(on, Fixtures.SENDING_ENV)
171
172 const session = Fixtures.firstPartySession(on, {
173 policy: { kind: 'settings', settings: { forceLoginMethod: 'gateway' } },
174 })
175
176 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
177 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
178
179 expect(session.posts).toEqual([])
180 },
181 )
182
183 test(
184 'unreadable switches send nothing, and the debug log says why',
185 { plugins: [Fixtures.recording] },
186 async ($, on) => {
187 mock.env(on, Fixtures.SENDING_ENV)
188
189 const session = Fixtures.firstPartySession(on, {
190 policy: { kind: 'unreadable' },
191 })
192
193 const { text } = await $.command.run(
194 Fixtures.record(Fixtures.surveyAnswer()),
195 )
196
197 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
198
199 expect({ text, posts: session.posts }).toEqual({
200 text: 'queued',
201 posts: [],
202 })
203
204 expect(session.reads, 'nothing of the machine is read either').toEqual([])
205
206 expect(session.lines.at(-1)).toStartWith(
207 'telemetry: the analytics switches could not be read, so nothing is ' +
208 'sent (',
209 )
210 },
211 )
212
213 test(
214 'a host-managed third-party provider follows DISABLE_TELEMETRY',
215 { plugins: [Fixtures.recording] },
216 async ($, on) => {
217 let environment = Fixtures.HOST_MANAGED_BEDROCK_ENV
218
219 on('env.get', ($, e) => ({ value: environment[e.name] }))
220
221 const session = Fixtures.firstPartySession(on)
222
223 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
224 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
225
226 const managed = session.posts.length
227
228 environment = { ...environment, DISABLE_TELEMETRY: '1' }
229
230 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
231 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
232
233 expect({ managed, afterOptOut: session.posts.length }).toEqual({
234 managed: 1,
235 afterOptOut: 1,
236 })
237 },
238 )
239
240 test(
241 'marks go out as the feature events, by their own names',
242 { plugins: [Fixtures.marking] },
243 async ($, on) => {
244 mock.env(on, Fixtures.SENDING_ENV)
245
246 const session = Fixtures.firstPartySession(on)
247 const answers: (string | undefined)[] = []
248
249 for (const entry of [
250 { feature: 'learn_page', kind: 'ok' },
251 { feature: 'learn_page', kind: 'sad', reason: 'blocked' },
252 { feature: 'suggest_learning', kind: 'bad', reason: 'api_error' },
253 ]) {
254 answers.push((await $.command.run(Fixtures.mark(entry))).text)
255 }
256
257 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
258
259 expect(answers).toEqual(['queued', 'queued', 'queued'])
260
261 expect(
262 Fixtures.rowsOf(session),
263 'one batch, the feature events by their own names, no plugin prefix',
264 ).toMatchObject([
265 {
266 event_name: 'tengu_feature_ok',
267 metadata: { feature_name: 'learn_page' },
268 },
269 {
270 event_name: 'tengu_feature_sad',
271 metadata: { feature_name: 'learn_page', error_code: 'blocked' },
272 },
273 {
274 event_name: 'tengu_feature_bad',
275 metadata: {
276 feature_name: 'suggest_learning',
277 error_code: 'api_error',
278 },
279 },
280 ])
281 },
282 )
283
284 test(
285 'a mark with a bad kind or a wrong reason is refused, nothing queued',
286 { plugins: [Fixtures.marking] },
287 async ($, on) => {
288 mock.env(on, Fixtures.SENDING_ENV)
289
290 const session = Fixtures.firstPartySession(on)
291
292 const refusalFor = async (entry: unknown) =>
293 (await $.command.run(Fixtures.mark(entry))).text
294
295 expect(
296 await refusalFor({ feature: 'learn_page', kind: 'meh' }),
297 ).toEndWith("$.telemetry.mark: kind: 'ok', 'sad' or 'bad'")
298
299 expect(
300 await refusalFor({ feature: 'learn_page', kind: 'bad' }),
301 ).toEndWith(
302 '$.telemetry.mark: reason: a bad mark names why, a snake_case token',
303 )
304
305 expect(
306 await refusalFor({ feature: 'learn_page', kind: 'ok', reason: 'x' }),
307 ).toEndWith('$.telemetry.mark: reason: an ok mark carries none')
308
309 expect(await refusalFor({ feature: 'Learn Page', kind: 'ok' })).toEndWith(
310 '$.telemetry.mark: takes a feature name, a snake_case token',
311 )
312
313 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
314
315 expect(session.posts).toEqual([])
316 },
317 )
318
319 test(
320 'with no first-party credential nothing is sent, and the debug log says so',
321 { plugins: [Fixtures.recording] },
322 async ($, on) => {
323 mock.env(on, Fixtures.SENDING_ENV)
324
325 const session = Fixtures.firstPartySession(on, {
326 authorizations: [null],
327 })
328
329 const { text } = await $.command.run(
330 Fixtures.record(Fixtures.surveyAnswer()),
331 )
332
333 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
334
335 expect({ text, posts: session.posts }).toEqual({
336 text: 'queued',
337 posts: [],
338 })
339
340 expect(session.lines.at(-1)).toBe(
341 'telemetry: 1 row(s) not sent: this session has no first-party ' +
342 'credential to authorize',
343 )
344 },
345 )
346
347 test(
348 'a server error is tried once more; a second one drops the batch',
349 { plugins: [Fixtures.recording] },
350 async ($, on) => {
351 mock.env(on, Fixtures.SENDING_ENV)
352
353 const session = Fixtures.firstPartySession(on, {
354 answers: [Fixtures.REFUSED, Fixtures.REFUSED],
355 })
356
357 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
358 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
359 await session.clock.advance(Hooks.RETRY_DELAY_MS)
360
361 expect(session.posts).toHaveLength(2)
362
363 expect(session.lines.at(-1)).toBe(
364 'telemetry: 1 row(s) not sent: the ingest answered 500',
365 )
366 },
367 )
368
369 test(
370 'a server error then an acceptance sends the batch',
371 { plugins: [Fixtures.recording] },
372 async ($, on) => {
373 mock.env(on, Fixtures.SENDING_ENV)
374
375 const session = Fixtures.firstPartySession(on, {
376 answers: [Fixtures.REFUSED, Fixtures.ACCEPTED],
377 })
378
379 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
380 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
381 await session.clock.advance(Hooks.RETRY_DELAY_MS)
382
383 expect(session.posts.map(post => post.init?.body)).toEqual([
384 session.posts[0]?.init?.body,
385 session.posts[0]?.init?.body,
386 ])
387
388 expect(session.lines.at(-1)).toBe('telemetry: sent 1 row(s)')
389 },
390 )
391
392 test(
393 'a client error is not retried',
394 { plugins: [Fixtures.recording] },
395 async ($, on) => {
396 mock.env(on, Fixtures.SENDING_ENV)
397
398 const session = Fixtures.firstPartySession(on, {
399 answers: [Fixtures.REJECTED],
400 })
401
402 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
403 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
404 await session.clock.advance(Hooks.RETRY_DELAY_MS)
405
406 expect(session.posts).toHaveLength(1)
407
408 expect(session.lines.at(-1)).toBe(
409 'telemetry: 1 row(s) not sent: the ingest answered 400',
410 )
411 },
412 )
413
414 test(
415 'free text and a malformed entry are refused, nothing queued',
416 { plugins: [Fixtures.recording] },
417 async ($, on) => {
418 mock.env(on, Fixtures.SENDING_ENV)
419
420 const session = Fixtures.firstPartySession(on)
421
422 const refusalFor = async (entry: unknown) =>
423 (
424 await $.command.run({
425 ...Fixtures.record(Fixtures.surveyAnswer()),
426 args: JSON.stringify(entry),
427 })
428 ).text
429
430 expect(
431 await refusalFor({ event: 'x', props: { note: 'hello world' } }),
432 ).toEndWith(
433 '$.telemetry.log: props.note: free text is refused; a string is a ' +
434 'Choice, { value, of: [...] }',
435 )
436
437 expect(
438 await refusalFor({
439 event: 'x',
440 props: { page: { value: 'elsewhere', of: ['ready', 'later'] } },
441 }),
442 ).toEndWith(
443 '$.telemetry.log: props.page.value: one of the members of `of`',
444 )
445
446 expect(await refusalFor({ event: 'Survey' })).toEndWith(
447 '$.telemetry.log: takes an event name, a snake_case token',
448 )
449
450 expect(
451 await refusalFor({ event: 'x', props: { 'a path': 1 } }),
452 ).toEndWith('$.telemetry.log: props: every key is a snake_case token')
453
454 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
455
456 expect(session.posts).toEqual([])
457 },
458 )
459
460 test(
461 'a number that is not finite is refused, nothing queued',
462 {
463 plugins: [
464 {
465 name: 'counting',
466 tier: 'builtin',
467 register(on) {
468 on('command.run', { command: 'count' }, $ =>
469 $.telemetry.log({ event: 'x', props: { n: Number.NaN } }).then(
470 () => ({ text: 'queued' }),
471 (error: unknown) => ({ text: String(error) }),
472 ),
473 )
474 },
475 },
476 ],
477 },
478 async ($, on) => {
479 mock.env(on, Fixtures.SENDING_ENV)
480
481 const session = Fixtures.firstPartySession(on)
482
483 const { text } = await $.command.run({
484 command: 'count',
485 args: '',
486 origin: { kind: 'composer' },
487 presentation: Fixtures.FULLSCREEN,
488 })
489
490 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
491
492 expect(text).toEndWith('$.telemetry.log: props.n: a number is finite')
493 expect(session.posts).toEqual([])
494 },
495 )
496
497 test(
498 'the config, the machine and the variables are read once a session',
499 { plugins: [Fixtures.recording] },
500 async ($, on) => {
501 mock.env(on, Fixtures.SENDING_ENV)
502
503 const session = Fixtures.firstPartySession(on)
504
505 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
506 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
507 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
508 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
509
510 expect({
511 posts: session.posts.length,
512 configReads: session.reads.filter(path => path === Fixtures.CONFIG_PATH)
513 .length,
514 runs: session.runs.map(run => run.argv.slice(0, 2)),
515 }).toEqual({ posts: 2, configReads: 1, runs: [['sh', '-c']] })
516 },
517 )
518
519 test(
520 'on Windows the platform is read off the environment and no program runs',
521 { plugins: [Fixtures.recording] },
522 async ($, on) => {
523 mock.env(on, Fixtures.WINDOWS_ENV)
524
525 const session = Fixtures.firstPartySession(on)
526
527 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
528 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
529
530 expect(Fixtures.rowsOf(session)[0]).toMatchObject({
531 env: {
532 platform: 'win32',
533 platform_raw: 'win32',
534 arch: 'x64',
535 shell: 'cmd',
536 terminal: 'windows-terminal',
537 deployment_environment: 'unknown-win32',
538 },
539 })
540
541 expect(session.runs).toEqual([])
542 },
543 )
544
545 test(
546 'on Linux the distribution, kernel, WSL and EC2 come from their files',
547 { plugins: [Fixtures.recording] },
548 async ($, on) => {
549 mock.env(on, { ...Fixtures.SENDING_ENV, WSL_DISTRO_NAME: 'Ubuntu' })
550
551 const session = Fixtures.firstPartySession(on, {
552 probeOutput: Fixtures.LINUX_PROBED,
553 files: Fixtures.LINUX_FILES,
554 existing: ['/.dockerenv'],
555 })
556
557 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
558 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
559
560 expect(Fixtures.rowsOf(session)[0]).toMatchObject({
561 env: {
562 platform: 'linux',
563 platform_raw: 'linux',
564 arch: 'x64',
565 terminal: 'ghostty',
566 package_managers: 'npm',
567 runtimes: 'node',
568 linux_distro_id: 'ubuntu',
569 linux_distro_version: '24.04',
570 linux_kernel: '6.6.87.2-microsoft-standard-WSL2',
571 wsl_version: '2',
572 deployment_environment: 'aws-ec2',
573 },
574 })
575 },
576 )
577
578 test(
579 'in a GitHub Actions run the client and the workflow ids ride along',
580 { plugins: [Fixtures.recording] },
581 async ($, on) => {
582 mock.env(on, Fixtures.GITHUB_ACTION_ENV)
583
584 const session = Fixtures.firstPartySession(on)
585
586 await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
587 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
588
589 expect(Fixtures.rowsOf(session)[0]).toMatchObject({
590 client_type: 'github-action',
591 entrypoint: 'claude-code-github-action',
592 env: {
593 is_ci: true,
594 is_github_action: true,
595 is_claude_code_action: true,
596 deployment_environment: 'github-actions',
597 github_event_name: 'pull_request',
598 github_actions_runner_os: 'Linux',
599 github_actions_runner_environment: 'github-hosted',
600 github_action_ref: 'v1',
601 github_actions_metadata: {
602 actor_id: '11',
603 repository_id: '22',
604 repository_owner_id: '33',
605 },
606 },
607 })
608 },
609 )
610
611 test(
612 'a failed authorize is not held against the next batch',
613 { plugins: [Fixtures.recording] },
614 async ($, on) => {
615 mock.env(on, Fixtures.SENDING_ENV)
616
617 const session = Fixtures.firstPartySession(on, {
618 authorizations: ['refused', Fixtures.BEARER],
619 })
620
621 await $.command.run(Fixtures.record({ event: 'x' }))
622 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
623
624 const postsAfterFirst = session.posts.length
625
626 await $.command.run(Fixtures.record({ event: 'x' }))
627 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
628
629 expect({ postsAfterFirst, posts: session.posts.length }).toEqual({
630 postsAfterFirst: 0,
631 posts: 1,
632 })
633
634 expect(session.lines[0]).toContain('the credential store is locked')
635 },
636 )
637
638 test(
639 'a plugin a person installed is refused, and its own hook carries on',
640 { plugins: [Fixtures.visiting] },
641 async ($, on) => {
642 mock.env(on, Fixtures.SENDING_ENV)
643
644 const session = Fixtures.firstPartySession(on)
645
646 const { text } = await $.command.run(
647 Fixtures.typed('visit', Fixtures.surveyAnswer()),
648 )
649
650 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
651
652 expect({ text, posts: session.posts }).toEqual({
653 text: `HooksError: visiting: $.telemetry.log: ${Hooks.REFUSED.deny}`,
654 posts: [],
655 })
656 },
657 )
658
659 test(
660 'a plugin an administrator prepended is refused too',
661 { plugins: [Fixtures.managing] },
662 async ($, on) => {
663 mock.env(on, Fixtures.SENDING_ENV)
664
665 const session = Fixtures.firstPartySession(on)
666
667 const { text } = await $.command.run(
668 Fixtures.typed('manage', { feature: 'learn_page', kind: 'ok' }),
669 )
670
671 await session.clock.advance(Hooks.BATCH_WINDOW_MS)
672
673 expect({ text, posts: session.posts }).toEqual({
674 text: `HooksError: managing: $.telemetry.mark: ${Hooks.REFUSED.deny}`,
675 posts: [],
676 })
677 },
678 )
679
680 test('the gate serves built-ins and core, throws on no origin', () => {
681 expect(tiers.map(tier => Hooks.served({}, Fixtures.nextOf(tier)))).toEqual([
682 'served',
683 'served',
684 Hooks.REFUSED,
685 Hooks.REFUSED,
686 Hooks.REFUSED,
687 ])
688
689 expect(() => Hooks.served({}, Fixtures.nextOf(undefined))).toThrow(
690 '$.telemetry: the call names no origin',
691 )
692 })
693
694 test('a gate that threw refuses; a refusal from beneath passes up', () => {
695 expect(Hooks.caught({}, Fixtures.failingNext(false))).toEqual(Hooks.REFUSED)
696
697 expect(() => Hooks.caught({}, Fixtures.failingNext(true))).toThrow(
698 'beneath refused the entry',
699 )
700 })
701})