Skip to content

Commit 6557bbe

Browse files
authored
telemetry: log and mark are what the mod's hooks do, the noun added only where the engine has none (#96917)
* 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
1 parent 684ffc4 commit 6557bbe

20 files changed

Lines changed: 260 additions & 32 deletions

File tree

‎mods/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ source, published as it is built into the binary.
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

‎mods/telemetry/.claude-plugin/plugin.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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
},

‎mods/telemetry/README.md‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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 @@ noun and a test answering it all read.
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 @@ are on, and nowhere else; it serves the plugins bundled with the CLI and
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".
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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+
)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './answer-of.js'
2+
3+
export * as default from '.'

‎mods/telemetry/hooks/entries/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ export * from './fields-of'
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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './is-refused-entry.js'
2+
3+
export * as default from '.'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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'
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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 @@ import type { Method } from '../method'
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 })
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type * from './refused-entry.js'
2+
3+
export * as default from '.'

0 commit comments

Comments
 (0)