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 * telemetry: the engine.create step spreads what is beneath last, so a telemetry it already has stands
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | | --- | --- | --- | |
| 10 | 10 | | [`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 | |
| 11 | 11 | | [`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 | | |
| 13 | 13 | | [`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 | |
| 14 | 14 | |
| 15 | 15 | Each folder is a complete plugin: `.claude-plugin/plugin.json`, a |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "telemetry", |
| 3 | 3 | "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.", | |
| 5 | 5 | "author": { |
| 6 | 6 | "name": "Anthropic" |
| 7 | 7 | }, |
| @@ -1,11 +1,16 @@ | ||
| 1 | 1 | # telemetry |
| 2 | 2 | |
| 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 | |
| 7 | 7 | administrator listed is refused with a reason (the host stamps every call |
| 8 | 8 | 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. | |
| 9 | 14 | `$.telemetry.log({ event, props })` queues one event as one first-party |
| 10 | 15 | row, `tengu_plugin_<event>`; `$.telemetry.mark({ feature, kind, reason?, |
| 11 | 16 | props? })` marks one use of a feature as the CLI's own feature events do, |
| @@ -59,11 +64,17 @@ | ||
| 59 | 64 | |
| 60 | 65 | ## What it hooks |
| 61 | 66 | |
| 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. | |
| 67 | 78 | |
| 68 | 79 | ## What it calls on `$` |
| 69 | 80 | |
| @@ -81,5 +92,6 @@ | ||
| 81 | 92 | refuses every other caller. It is not meant to be installed or loaded with |
| 82 | 93 | `--plugin-dir`; the folder has a manifest so it reads like every other |
| 83 | 94 | 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 @@ | ||
| 15 | 15 | export * from './ingest-url.js' |
| 16 | 16 | export * from './is-mark-kind' |
| 17 | 17 | export * from './is-record' |
| 18 | +export * from './is-refused-entry' | |
| 18 | 19 | export * from './mark' |
| 19 | 20 | export * from './mark-fields-of' |
| 20 | 21 | export * from './mark-kinds' |
| 21 | 22 | export * from './method' |
| 22 | 23 | export * from './prop-limit' |
| 23 | 24 | export * from './refusal' |
| 25 | +export * from './refused-entry' | |
| 24 | 26 | export * from './token' |
| 25 | 27 | export * from './wire-of' |
| 26 | 28 |
| @@ -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 @@ | ||
| 1 | 1 | import type { Method } from '../method' |
| 2 | +import type { RefusedEntry } from '../refused-entry' | |
| 2 | 3 | |
| 3 | 4 | /** |
| 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`. | |
| 5 | 7 | * |
| 6 | 8 | * The text carries a key that passed TOKEN or a status code, nothing the caller |
| 7 | 9 | * wrote as free text. |
| @@ -11,5 +13,5 @@ | ||
| 11 | 13 | * @returns the error to reject the call with, naming the method and what was |
| 12 | 14 | * wrong |
| 13 | 15 | */ |
| 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 @@ | ||
| 1 | 1 | { |
| 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", | |
| 3 | 3 | "modules": [ |
| 4 | 4 | "./register.ts" |
| 5 | 5 | ] |
| @@ -1,3 +1,4 @@ | ||
| 1 | +export * from './answer-of' | |
| 1 | 2 | export * from './batching' |
| 2 | 3 | export * from './config-location' |
| 3 | 4 | export * from './context' |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import type { EngineInterface, On } from 'claude-code' |
| 2 | 2 | |
| 3 | +import { answerOf } from './answer-of' | |
| 3 | 4 | import Context from './context' |
| 4 | 5 | import Gate from './gate' |
| 5 | 6 | import IsAnalyticsOff from './is-analytics-off' |
| @@ -7,12 +8,15 @@ | ||
| 7 | 8 | import { telemetryOf } from './telemetry-of' |
| 8 | 9 | |
| 9 | 10 | /** |
| 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. | |
| 12 | 14 | * |
| 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. | |
| 16 | 20 | * |
| 17 | 21 | * @param on the engine's registrar |
| 18 | 22 | */ |
| @@ -24,6 +28,16 @@ | ||
| 24 | 28 | (_$, e, next) => Gate.caught(e, next), |
| 25 | 29 | ) |
| 26 | 30 | |
| 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 | + | |
| 27 | 41 | on('session.start', (_$, e, next) => { |
| 28 | 42 | isInteractive = e.isInteractive |
| 29 | 43 | |
| @@ -348,7 +362,8 @@ | ||
| 348 | 362 | }) |
| 349 | 363 | |
| 350 | 364 | const telemetry: EngineInterface['telemetry'] = sender.telemetry |
| 365 | + const added = { telemetry } | |
| 351 | 366 | |
| 352 | - return { ...beneath, telemetry } | |
| 367 | + return { ...added, ...beneath } | |
| 353 | 368 | }) |
| 354 | 369 | } |
| @@ -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' | |
| 1 | 2 | export * from './holding.js' |
| 2 | 3 | export * from './managing.js' |
| 3 | 4 | export * from './marking.js' |
| @@ -6,6 +7,7 @@ | ||
| 6 | 7 | export * from './recording.js' |
| 7 | 8 | export * from './replacing.js' |
| 8 | 9 | export * from './swallowing.js' |
| 10 | +export * from './sweeping.js' | |
| 9 | 11 | export * from './visiting.js' |
| 10 | 12 | |
| 11 | 13 | 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 @@ | ||
| 124 | 124 | }) |
| 125 | 125 | }, |
| 126 | 126 | ) |
| 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 | + ) | |
| 127 | 183 | }) |
| @@ -497,6 +497,37 @@ | ||
| 497 | 497 | ) |
| 498 | 498 | |
| 499 | 499 | 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( | |
| 500 | 531 | 'a number that is not finite is refused, nothing queued', |
| 501 | 532 | { |
| 502 | 533 | plugins: [ |
| @@ -2,20 +2,21 @@ | ||
| 2 | 2 | * The `$.telemetry` noun as every caller sees it: the one contract for the |
| 3 | 3 | * noun, its types exported here and the noun declared on `EngineInterface`. |
| 4 | 4 | * |
| 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. | |
| 10 | 12 | */ |
| 11 | 13 | |
| 12 | 14 | /** |
| 13 | 15 | * A plugin's analytics, queued through `$.telemetry` and sent in batches. |
| 14 | 16 | * |
| 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`. | |
| 19 | 20 | */ |
| 20 | 21 | export type Telemetry = { |
| 21 | 22 | /** |
| @@ -63,10 +64,22 @@ | ||
| 63 | 64 | } |
| 64 | 65 | |
| 65 | 66 | /** |
| 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 | +/** | |
| 66 | 74 | * What `$.telemetry.log` takes: the event's name after the prefix, and its |
| 67 | 75 | * 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. | |
| 68 | 80 | */ |
| 69 | 81 | export type TelemetryLogEntry = { |
| 82 | + to?: TelemetryDestination | |
| 70 | 83 | event: string |
| 71 | 84 | props?: Readonly<Record<string, TelemetryProp>> |
| 72 | 85 | } |