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

telemetry: log and mark are what the mod's hooks do, the noun added only where the engine has none (#96917)Browse files
* telemetry: log and mark are what the mod's hooks do, the noun added only where the engine has none

* telemetry: the engine.create step spreads what is beneath last, so a telemetry it already has stands
Alice T'Poteat authored 29 days ago · GitHub committed 29 days ago parent 684ffc4 · commit 6557bbe6b600
20 changed files+260 -32compared with 684ffc4
Mmods/README.md
Mmods/telemetry/.claude-plugin/plugin.json
Mmods/telemetry/README.md
Amods/telemetry/hooks/answer-of/answer-of.ts
Amods/telemetry/hooks/answer-of/index.ts
Mmods/telemetry/hooks/entries/index.ts
Amods/telemetry/hooks/entries/is-refused-entry/index.ts
Amods/telemetry/hooks/entries/is-refused-entry/is-refused-entry.ts
Mmods/telemetry/hooks/entries/refusal/refusal.ts
Amods/telemetry/hooks/entries/refused-entry/index.ts
Amods/telemetry/hooks/entries/refused-entry/refused-entry.ts
Mmods/telemetry/hooks/hooks.json
Mmods/telemetry/hooks/index.ts
Mmods/telemetry/hooks/register.ts
Amods/telemetry/tests/fixtures/plugins/annotating.ts
Mmods/telemetry/tests/fixtures/plugins/index.ts
Amods/telemetry/tests/fixtures/plugins/sweeping.ts
Mmods/telemetry/tests/gate.test.ts
Mmods/telemetry/tests/register.test.ts
Mmods/telemetry/types/index.d.ts
@@ -9,7 +9,7 @@
99 | --- | --- | --- |
1010 | [`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 |
1111 | [`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 |
12-| [`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 |
12+| [`telemetry`](telemetry) | Hooks `$.telemetry`'s two events (`log`, `mark`), adding the noun in the `engine.create` fold where the engine has none, 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 |
1313 | [`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 |
1414
1515 Each folder is a complete plugin: `.claude-plugin/plugin.json`, a
@@ -1,7 +1,7 @@
11 {
22 "name": "telemetry",
33 "version": "0.1.0",
4- "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.",
4+ "description": "Plugin analytics: hooks $.telemetry's two events, 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.",
55 "author": {
66 "name": "Anthropic"
77 },
@@ -1,11 +1,16 @@
11 # telemetry
22
3-Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry` to
4-the engine interface every plugin above it is handed, built over the nouns
5-beneath, and a hook on its own two events serves the plugins built into
6-Claude Code alone: a call from a plugin a person installed or an
3+Plugin analytics as a plugin: its hooks on `telemetry.log` and
4+`telemetry.mark` are what those two events do, built over the nouns its
5+`engine.create` step is handed, and a gate above them serves the plugins
6+built into Claude Code alone: a call from a plugin a person installed or an
77 administrator listed is refused with a reason (the host stamps every call
88 with the plugin that raised it, `next.origin`, and the gate reads its tier).
9+On an engine that has no `$.telemetry` of its own the same step adds the
10+noun, so the calls exist there too. An entry names where it goes with `to`:
11+`anthropic`, the default, is this mod's; one for `collector`, the telemetry
12+collector an operator configured, is passed on beneath untouched, and `to`
13+is never part of a row.
914 `$.telemetry.log({ event, props })` queues one event as one first-party
1015 row, `tengu_plugin_<event>`; `$.telemetry.mark({ feature, kind, reason?,
1116 props? })` marks one use of a feature as the CLI's own feature events do,
@@ -59,11 +64,17 @@
5964
6065 ## What it hooks
6166
62-`engine.create`: `{ ...await next(e), telemetry }`, so the noun is added and
63-nothing beneath is replaced. `telemetry.*`, the gate: a caller in the
64-built-in tier (or the engine) goes on, any other is refused, and a gate
65-that throws refuses too. `session.start`, to learn whether a person is at
66-the prompt; `session.end`, to send what still waits.
67+`telemetry.*`, the gate: a caller in the built-in tier (or the engine) goes
68+on, any other is refused, and a gate that throws refuses too.
69+`telemetry.log` and `telemetry.mark`, beneath the gate: the entry is checked
70+and its row queued, the hook answering `{ value }`, or `{ deny }` with the
71+reason for an entry that breaks a rule, so the caller's promise rejects
72+naming it. `engine.create`: the sender is built over `await next(e)`, and
73+the step hands up `{ ...{ telemetry }, ...beneath }`: what is beneath is
74+spread last, so its own `telemetry` stands where it has one and this mod's
75+is added where it has none; nothing beneath is replaced.
76+`session.start`, to learn whether a person is at the prompt; `session.end`,
77+to send what still waits.
6778
6879 ## What it calls on `$`
6980
@@ -81,5 +92,6 @@
8192 refuses every other caller. It is not meant to be installed or loaded with
8293 `--plugin-dir`; the folder has a manifest so it reads like every other
8394 plugin, not so it can stand alone. A built-in that calls `$.telemetry`
84-where this one is absent finds no such noun and should treat that as "no
85-analytics here".
95+where this one is absent finds no such noun, or, on an engine with the noun
96+of its own, one whose calls queue nothing; either way that is "no analytics
97+here".
@@ -1,0 +1,24 @@
1+import Entries from '../entries'
2+
3+/**
4+ * A row's queueing as the hook on its event answers it: `{ value }` once
5+ * the row is queued, and a refused entry as `{ deny }` with its reason, so
6+ * the caller's promise rejects naming what was wrong.
7+ *
8+ * Anything else that went wrong is thrown on: the hook failed, and the
9+ * engine goes on beneath it as it does for any failed hook.
10+ *
11+ * @param queued the queueing, settled once the entry is checked and queued
12+ * @returns the hook's answer
13+ */
14+export const answerOf = (queued: Promise<void>) =>
15+ queued.then(
16+ () => ({ value: undefined }),
17+ (error: unknown) => {
18+ if (Entries.isRefusedEntry(error)) {
19+ return { deny: error.what }
20+ }
21+
22+ throw error
23+ },
24+ )
@@ -1,0 +1,3 @@
1+export * from './answer-of.js'
2+
3+export * as default from '.'
@@ -15,12 +15,14 @@
1515 export * from './ingest-url.js'
1616 export * from './is-mark-kind'
1717 export * from './is-record'
18+export * from './is-refused-entry'
1819 export * from './mark'
1920 export * from './mark-fields-of'
2021 export * from './mark-kinds'
2122 export * from './method'
2223 export * from './prop-limit'
2324 export * from './refusal'
25+export * from './refused-entry'
2426 export * from './token'
2527 export * from './wire-of'
2628
@@ -1,0 +1,3 @@
1+export * from './is-refused-entry.js'
2+
3+export * as default from '.'
@@ -1,0 +1,11 @@
1+import type { RefusedEntry } from '../refused-entry'
2+
3+/**
4+ * Whether an error is an entry's refusal (`refusal`), and so carries the
5+ * reason to deny with, rather than something that went wrong on the way.
6+ *
7+ * @param error what a check threw
8+ * @returns whether it is a refused entry's error
9+ */
10+export const isRefusedEntry = (error: unknown): error is RefusedEntry =>
11+ error instanceof Error && 'what' in error && typeof error.what === 'string'
@@ -1,7 +1,9 @@
11 import type { Method } from '../method'
2+import type { RefusedEntry } from '../refused-entry'
23
34 /**
4- * The error a refused entry rejects with, naming the method and what was wrong.
5+ * The error a refused entry rejects with, naming the method and what was
6+ * wrong, the reason kept beside the message as `what`.
57 *
68 * The text carries a key that passed TOKEN or a status code, nothing the caller
79 * wrote as free text.
@@ -11,5 +13,5 @@
1113 * @returns the error to reject the call with, naming the method and what was
1214 * wrong
1315 */
14-export const refusal = (what: string, method: Method = 'log'): Error =>
15- new Error(`$.telemetry.${method}: ${what}`)
16+export const refusal = (what: string, method: Method = 'log'): RefusedEntry =>
17+ Object.assign(new Error(`$.telemetry.${method}: ${what}`), { what })
@@ -1,0 +1,3 @@
1+export type * from './refused-entry.js'
2+
3+export * as default from '.'
@@ -1,0 +1,6 @@
1+/**
2+ * The error a refused entry rejects with: its message names the method and
3+ * what was wrong, and `what` is that reason alone, for a hook to answer as
4+ * its `{ deny }`.
5+ */
6+export type RefusedEntry = Error & { readonly what: string }
@@ -1,5 +1,5 @@
11 {
2- "description": "Plugin analytics: an engine.create step adds $.telemetry (log, mark) over $.session, $.env, $.settings, $.fs, $.process, $.clock and $.http beneath; rows go out in batches; sends nothing wherever the CLI's analytics are off",
2+ "description": "Plugin analytics: hooks on telemetry.log and telemetry.mark queue first-party rows over $.session, $.env, $.settings, $.fs, $.process, $.clock and $.http beneath, the noun added in engine.create where the engine has none; rows go out in batches; sends nothing wherever the CLI's analytics are off",
33 "modules": [
44 "./register.ts"
55 ]
@@ -1,3 +1,4 @@
1+export * from './answer-of'
12 export * from './batching'
23 export * from './config-location'
34 export * from './context'
@@ -1,5 +1,6 @@
11 import type { EngineInterface, On } from 'claude-code'
22
3+import { answerOf } from './answer-of'
34 import Context from './context'
45 import Gate from './gate'
56 import IsAnalyticsOff from './is-analytics-off'
@@ -7,12 +8,15 @@
78 import { telemetryOf } from './telemetry-of'
89
910 /**
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.
11+ * Registers the plugin's hooks: the gate on `telemetry.*`, the two hooks
12+ * that are what `telemetry.log` and `telemetry.mark` do, and its
13+ * engine.create step, which builds the sender over the nouns beneath.
1214 *
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.
15+ * A row is queued for a built-in caller and refused to any other; a
16+ * malformed entry is denied with its reason; an entry for `collector` is
17+ * not this plugin's and goes on beneath. On an engine with no `telemetry`
18+ * of its own the step adds the noun, its methods the same queueing. A batch
19+ * goes out on a timer, when full, and when the session ends.
1620 *
1721 * @param on the engine's registrar
1822 */
@@ -24,6 +28,16 @@
2428 (_$, e, next) => Gate.caught(e, next),
2529 )
2630
31+ on('telemetry.log', (_$, e, next) =>
32+ sender === undefined || e.to === 'collector'
33+ ? next(e)
34+ : answerOf(sender.telemetry.log(e)),
35+ )
36+
37+ on('telemetry.mark', (_$, e, next) =>
38+ sender === undefined ? next(e) : answerOf(sender.telemetry.mark(e)),
39+ )
40+
2741 on('session.start', (_$, e, next) => {
2842 isInteractive = e.isInteractive
2943
@@ -348,7 +362,8 @@
348362 })
349363
350364 const telemetry: EngineInterface['telemetry'] = sender.telemetry
365+ const added = { telemetry }
351366
352- return { ...beneath, telemetry }
367+ return { ...added, ...beneath }
353368 })
354369 }
@@ -1,0 +1,22 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin a person installed whose one hook is on every event: it sends
5+ * each `telemetry.log` entry on beneath with a note of its own beside the
6+ * entry's fields, and the rest untouched.
7+ */
8+export const annotating: Plugin = {
9+ name: 'annotating',
10+ tier: 'user',
11+ register(on) {
12+ on('*', (_$, e, next) => {
13+ if (!next.is('telemetry.log', e)) {
14+ return next(e)
15+ }
16+
17+ const noted = { ...e, note: 'what the person typed at the prompt' }
18+
19+ return next(noted)
20+ })
21+ },
22+}
@@ -1,3 +1,4 @@
1+export * from './annotating.js'
12 export * from './holding.js'
23 export * from './managing.js'
34 export * from './marking.js'
@@ -6,6 +7,7 @@
67 export * from './recording.js'
78 export * from './replacing.js'
89 export * from './swallowing.js'
10+export * from './sweeping.js'
911 export * from './visiting.js'
1012
1113 export * as default from '.'
@@ -1,0 +1,22 @@
1+import type { Plugin } from 'claude-code/testing'
2+
3+/**
4+ * A plugin a person installed whose one hook is on every event: it sends
5+ * each `telemetry.log` entry on beneath turned to the other destination,
6+ * and the rest untouched.
7+ */
8+export const sweeping: Plugin = {
9+ name: 'sweeping',
10+ tier: 'user',
11+ register(on) {
12+ on('*', (_$, e, next) => {
13+ if (!next.is('telemetry.log', e)) {
14+ return next(e)
15+ }
16+
17+ const turned = { ...e, to: 'collector' }
18+
19+ return next(turned)
20+ })
21+ },
22+}
@@ -124,4 +124,60 @@
124124 })
125125 },
126126 )
127+
128+ test(
129+ 'a hook on every event cannot turn a row to the other destination',
130+ { plugins: [Fixtures.recording, Fixtures.sweeping] },
131+ async ($, on) => {
132+ mock.env(on, Fixtures.SENDING_ENV)
133+
134+ const session = Fixtures.firstPartySession(on)
135+
136+ await $.session.start(Fixtures.STARTED)
137+
138+ const answer = (
139+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
140+ ).text
141+
142+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
143+
144+ expect({
145+ answer,
146+ posts: session.posts.length,
147+ rows: Fixtures.rowsOf(session),
148+ }).toEqual({
149+ answer: 'queued',
150+ posts: 1,
151+ rows: [Fixtures.EXPECTED_ROW],
152+ })
153+ },
154+ )
155+
156+ test(
157+ 'what a hook on every event adds beside a row never leaves in it',
158+ { plugins: [Fixtures.recording, Fixtures.annotating] },
159+ async ($, on) => {
160+ mock.env(on, Fixtures.SENDING_ENV)
161+
162+ const session = Fixtures.firstPartySession(on)
163+
164+ await $.session.start(Fixtures.STARTED)
165+
166+ const answer = (
167+ await $.command.run(Fixtures.record(Fixtures.surveyAnswer()))
168+ ).text
169+
170+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
171+
172+ expect({
173+ answer,
174+ posts: session.posts.length,
175+ rows: Fixtures.rowsOf(session),
176+ }).toEqual({
177+ answer: 'queued',
178+ posts: 1,
179+ rows: [Fixtures.EXPECTED_ROW],
180+ })
181+ },
182+ )
127183 })
@@ -497,6 +497,37 @@
497497 )
498498
499499 test(
500+ 'a refused entry is denied by the hook, naming the caller and the reason',
501+ { plugins: [Fixtures.recording, Fixtures.marking] },
502+ async ($, on) => {
503+ mock.env(on, Fixtures.SENDING_ENV)
504+
505+ const session = Fixtures.firstPartySession(on)
506+
507+ const logged = (
508+ await $.command.run(Fixtures.typed('record', { event: 'Survey' }))
509+ ).text
510+
511+ const marked = (
512+ await $.command.run(
513+ Fixtures.typed('mark', { feature: 'learn_page', kind: 'meh' }),
514+ )
515+ ).text
516+
517+ await session.clock.advance(Hooks.BATCH_WINDOW_MS)
518+
519+ expect({ logged, marked, posts: session.posts }).toEqual({
520+ logged:
521+ 'HooksError: recording: $.telemetry.log: takes an event name, a ' +
522+ 'snake_case token',
523+ marked:
524+ "HooksError: marking: $.telemetry.mark: kind: 'ok', 'sad' or 'bad'",
525+ posts: [],
526+ })
527+ },
528+ )
529+
530+ test(
500531 'a number that is not finite is refused, nothing queued',
501532 {
502533 plugins: [
@@ -2,20 +2,21 @@
22 * The `$.telemetry` noun as every caller sees it: the one contract for the
33 * noun, its types exported here and the noun declared on `EngineInterface`.
44 *
5- * The telemetry mod adds the noun in the `engine.create` fold and checks its
6- * return against `EngineInterface['telemetry']`; its hooks import these types
7- * from this folder, a mod that calls the noun and a test that answers it read
8- * them by including it in their tsconfig, and the engine's repository imports
9- * the folder by path. Nothing here is imported, so it stands on its own.
5+ * The telemetry mod hooks the noun's two events, `telemetry.log` and
6+ * `telemetry.mark`, and is what queues and sends a row; on an engine that
7+ * has no `telemetry` of its own it also adds the noun in the `engine.create`
8+ * fold, checked against `EngineInterface['telemetry']`. Its hooks import
9+ * these types from this folder, a mod that calls the noun and a test that
10+ * answers it read them by including it in their tsconfig. Nothing here is
11+ * imported, so it stands on its own.
1012 */
1113
1214 /**
1315 * A plugin's analytics, queued through `$.telemetry` and sent in batches.
1416 *
15- * The telemetry mod adds the noun in the `engine.create` fold wherever the
16- * CLI seats it, which is every build whose own analytics are on, and serves
17- * the plugins built into the CLI alone: a call from an installed plugin
18- * rejects. Where the mod is off or absent there is no `$.telemetry`.
17+ * The telemetry mod serves the plugins built into the CLI alone: a call from
18+ * an installed plugin rejects. Where the mod is off or absent nothing is
19+ * queued, and on an engine without the noun there is no `$.telemetry`.
1920 */
2021 export type Telemetry = {
2122 /**
@@ -63,10 +64,22 @@
6364 }
6465
6566 /**
67+ * Where a logged record goes: `anthropic`, the first-party analytics this
68+ * mod sends, or `collector`, the telemetry collector a session's operator
69+ * configured, which this mod leaves to whatever is beneath it.
70+ */
71+export type TelemetryDestination = 'anthropic' | 'collector'
72+
73+/**
6674 * What `$.telemetry.log` takes: the event's name after the prefix, and its
6775 * properties by snake_case key.
76+ *
77+ * `to` names the destination and is never part of the row; left out, it
78+ * reads as `anthropic`. An entry for `collector` is not this mod's: its
79+ * hook passes it on beneath untouched.
6880 */
6981 export type TelemetryLogEntry = {
82+ to?: TelemetryDestination
7083 event: string
7184 props?: Readonly<Record<string, TelemetryProp>>
7285 }