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/telemetry: a mark carries props, checked as a log's are and merged as the CLI's feature marks merge their extrasBrowse files
poteat authored on Sep 11, 2026 parent f6cd2ca · commit 8dfc5aaf3619
10 changed files+100 -51compared with f6cd2ca
Mmods/telemetry/README.md
Mmods/telemetry/hooks/entries/checked-fields.ts
Mmods/telemetry/hooks/entries/checked-mark.ts
Amods/telemetry/hooks/entries/checked-props/checked-props.ts
Amods/telemetry/hooks/entries/checked-props/index.ts
Mmods/telemetry/hooks/entries/checked-value/checked-value.ts
Mmods/telemetry/hooks/entries/index.ts
Mmods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts
Mmods/telemetry/hooks/telemetry-types/mark-entry/mark-entry.ts
Mmods/telemetry/hooks/telemetry-types/telemetry.ts
@@ -1,15 +1,16 @@
11 # telemetry
22
3-Plugin analytics as a plugin: one `engine.create` step adds `$.telemetry`
4-to the engine interface every plugin above it is handed, built over the
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
55 `$.session` and `$.http` nouns beneath. `$.telemetry.log({ event, props })`
66 sends one event as one first-party row, `tengu_plugin_<event>`;
7-`$.telemetry.mark({ feature, kind, reason? })` marks one use of a feature as
8-the CLI's own feature events do, `tengu_feature_<kind>` with a
9-`feature_name`. Each call is one POST to the event-logging ingest with the
10-session's own credential (`$.session.authorize()`, resolved at each call),
11-one attempt, nothing batched; a session with no first-party credential, or
12-an ingest that refuses, rejects the caller's promise.
7+`$.telemetry.mark({ feature, kind, reason?, props? })` marks one use of a
8+feature as the CLI's own feature events do, `tengu_feature_<kind>` with a
9+`feature_name` and the mark's properties beside it. Each call is one POST to
10+the event-logging ingest with the session's own credential
11+(`$.session.authorize()`, resolved at each call), one attempt, nothing
12+batched; a session with no first-party credential, or an ingest that
13+refuses, rejects the caller's promise.
1314
1415 It sends nothing wherever the CLI's own analytics are off: under
1516 `DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
@@ -22,9 +23,10 @@
2223
2324 Nothing free-form reaches a row. An event name and every property key is a
2425 snake_case token; a value is a finite number, a boolean, or a Choice (a
25-string named together with the list it is chosen from); `mark` takes `ok`,
26-`sad` or `bad`, with a `reason` required on the last two and refused on the
27-first. An entry that breaks a rule is refused before anything is sent.
26+string named together with the list it is chosen from), under `log` and
27+`mark` alike; `mark` takes `ok`, `sad` or `bad`, with a `reason` required on
28+the last two and refused on the first. An entry that breaks a rule is
29+refused before anything is sent.
2830
2931 `hooks/register.ts` is the module; `hooks/telemetry-types/` is the noun's
3032 type as a caller sees it.
@@ -1,8 +1,7 @@
1-import { checkedValue } from './checked-value'
1+import { checkedProps } from './checked-props'
22 import type { Fields } from './fields'
33 import { fieldsOf } from './fields-of'
44 import { isRecord } from './is-record'
5-import { PROP_LIMIT } from './prop-limit'
65 import { refusal } from './refusal'
76 import { TOKEN } from './token'
87
@@ -27,25 +26,5 @@
2726 throw refusal('takes an event name, a snake_case token')
2827 }
2928
30- if (!isRecord(props)) {
31- throw refusal('props: an object of properties by key')
32- }
33-
34- const entries = Object.entries(props)
35-
36- if (entries.length > PROP_LIMIT) {
37- throw refusal(`props: at most ${PROP_LIMIT} properties`)
38- }
39-
40- const checked: Record<string, string | number | boolean> = {}
41-
42- for (const [key, value] of entries) {
43- if (!TOKEN.test(key)) {
44- throw refusal('props: every key is a snake_case token')
45- }
46-
47- checked[key] = checkedValue(key, value)
48- }
49-
50- return fieldsOf(event, checked)
29+ return fieldsOf(event, checkedProps(props, 'log'))
5130 }
@@ -1,3 +1,4 @@
1+import { checkedProps } from './checked-props'
12 import type { Fields } from './fields'
23 import { isMarkKind } from './is-mark-kind'
34 import { isRecord } from './is-record'
@@ -7,17 +8,19 @@
78
89 /**
910 * The row's fields from one mark, or a refusal naming the first thing
10- * wrong: the entry's shape, the feature, the kind, then the reason.
11+ * wrong: the entry's shape, the feature, the kind, the reason, then each
12+ * property in turn.
1113 *
12- * @param entry the feature, kind and reason as the caller passed them
14+ * @param entry the feature, kind, reason and properties as the caller passed
15+ * them
1316 * @returns the mark's row fields once the entry passes every check
1417 */
1518 export function checkedMark(entry: unknown): Fields {
1619 if (!isRecord(entry)) {
17- throw refusal('takes one entry, { feature, kind, reason? }', 'mark')
20+ throw refusal('takes one entry, { feature, kind, reason?, props? }', 'mark')
1821 }
1922
20- const { feature, kind, reason } = entry
23+ const { feature, kind, reason, props = {} } = entry
2124
2225 if (typeof feature !== 'string' || !TOKEN.test(feature)) {
2326 throw refusal('takes a feature name, a snake_case token', 'mark')
@@ -32,7 +35,7 @@
3235 throw refusal('reason: an ok mark carries none', 'mark')
3336 }
3437
35- return markFieldsOf(kind, feature)
38+ return markFieldsOf(kind, feature, undefined, checkedProps(props, 'mark'))
3639 }
3740
3841 if (typeof reason !== 'string' || !TOKEN.test(reason)) {
@@ -42,5 +45,5 @@
4245 )
4346 }
4447
45- return markFieldsOf(kind, feature, reason)
48+ return markFieldsOf(kind, feature, reason, checkedProps(props, 'mark'))
4649 }
@@ -1,0 +1,41 @@
1+import type TelemetryTypes from '../../telemetry-types'
2+import { checkedValue } from '../checked-value'
3+import { isRecord } from '../is-record'
4+import { PROP_LIMIT } from '../prop-limit'
5+import { refusal } from '../refusal'
6+import { TOKEN } from '../token'
7+
8+/**
9+ * An entry's properties as they go into the row, or a refusal naming the
10+ * first thing wrong: the shape, the count, then each key and value in turn.
11+ *
12+ * @param props what the caller passed as `props`
13+ * @param method the method the properties were passed to, named in a refusal
14+ * @returns the properties by key, each value checked
15+ */
16+export function checkedProps(
17+ props: unknown,
18+ method: TelemetryTypes.Method,
19+): Record<string, string | number | boolean> {
20+ if (!isRecord(props)) {
21+ throw refusal('props: an object of properties by key', method)
22+ }
23+
24+ const entries = Object.entries(props)
25+
26+ if (entries.length > PROP_LIMIT) {
27+ throw refusal(`props: at most ${PROP_LIMIT} properties`, method)
28+ }
29+
30+ const checked: Record<string, string | number | boolean> = {}
31+
32+ for (const [key, value] of entries) {
33+ if (!TOKEN.test(key)) {
34+ throw refusal('props: every key is a snake_case token', method)
35+ }
36+
37+ checked[key] = checkedValue(key, value, method)
38+ }
39+
40+ return checked
41+}
@@ -1,0 +1,3 @@
1+export * from './checked-props.js'
2+
3+export * as default from '.'
@@ -1,3 +1,4 @@
1+import type TelemetryTypes from '../../telemetry-types'
12 import { CHOICE_TOKEN } from '../choice-token'
23 import { CHOICES_LIMIT } from '../choices-limit'
34 import { isRecord } from '../is-record'
@@ -11,12 +12,14 @@
1112 *
1213 * @param key the property's key, named in a refusal
1314 * @param value what the caller passed under it
15+ * @param method the method the property was passed to, named in a refusal
1416 * @returns the value as stored: the boolean or finite number unchanged, or the
1517 * chosen member when value is a Choice
1618 */
1719 export function checkedValue(
1820 key: string,
1921 value: unknown,
22+ method: TelemetryTypes.Method = 'log',
2023 ): string | number | boolean {
2124 if (typeof value === 'boolean') {
2225 return value
@@ -27,13 +30,14 @@
2730 return value
2831 }
2932
30- throw refusal(`props.${key}: a number is finite`)
33+ throw refusal(`props.${key}: a number is finite`, method)
3134 }
3235
3336 if (typeof value === 'string') {
3437 throw refusal(
3538 `props.${key}: free text is refused; a string is a Choice, ` +
3639 `{ value, of: [...] }`,
40+ method,
3741 )
3842 }
3943
@@ -41,6 +45,7 @@
4145 throw refusal(
4246 `props.${key}: a value is a finite number, a boolean, or a Choice, ` +
4347 `{ value, of: [...] }`,
48+ method,
4449 )
4550 }
4651
@@ -56,11 +61,12 @@
5661 if (!isTokenList) {
5762 throw refusal(
5863 `props.${key}.of: a list of 1 to ${CHOICES_LIMIT} ` + `snake_case tokens`,
64+ method,
5965 )
6066 }
6167
6268 if (typeof chosen !== 'string' || !members.includes(chosen)) {
63- throw refusal(`props.${key}.value: one of the members of \`of\``)
69+ throw refusal(`props.${key}.value: one of the members of \`of\``, method)
6470 }
6571
6672 return chosen
@@ -1,6 +1,7 @@
11 export * from './batch-of.js'
22 export * from './checked-fields.js'
33 export * from './checked-mark.js'
4+export * from './checked-props'
45 export * from './checked-value'
56 export * from './choice-token'
67 export * from './choices-limit'
@@ -4,21 +4,26 @@
44
55 /**
66 * One checked mark as its fields: the CLI's own feature event,
7- * `tengu_feature_<kind>` with `feature_name`, and `error_code` when given.
7+ * `tengu_feature_<kind>` with `feature_name`, `error_code` when given, and
8+ * the mark's properties merged in as the CLI's feature events merge their
9+ * extras: after `feature_name` on an ok row, and beneath `feature_name` and
10+ * `error_code` on a sad or bad one.
811 *
912 * @param kind how the feature went
1013 * @param feature the feature marked
1114 * @param reason why, on a sad or bad mark; absent on ok
15+ * @param props the mark's checked properties
1216 * @returns the event's name and props, ready to log
1317 */
1418 export const markFieldsOf = (
1519 kind: TelemetryTypes.MarkKind,
1620 feature: string,
1721 reason?: string,
22+ props: Readonly<Record<string, string | number | boolean>> = {},
1823 ): Fields => ({
1924 name: FEATURE_PREFIX + kind,
2025 props:
2126 reason === undefined
22- ? { feature_name: feature }
23- : { feature_name: feature, error_code: reason },
27+ ? { feature_name: feature, ...props }
28+ : { ...props, feature_name: feature, error_code: reason },
2429 })
@@ -1,11 +1,13 @@
11 import type { MarkKind } from '../mark-kind'
2+import type { Prop } from '../prop'
23
34 /**
4- * What `$.telemetry.mark` takes: the feature, how it went, and why when not
5- * ok.
5+ * What `$.telemetry.mark` takes: the feature, how it went, why when not
6+ * ok, and the properties the row carries beside them by snake_case key.
67 */
78 export type MarkEntry = {
89 feature: string
910 kind: MarkKind
1011 reason?: string
12+ props?: Readonly<Record<string, Prop>>
1113 }
@@ -36,15 +36,22 @@
3636 * Marks one use of a feature as the CLI's own feature events do, one
3737 * `tengu_feature_<kind>` row; resolves once the ingest accepted it.
3838 *
39- * The row carries `feature_name`, and `error_code` on a sad or bad one; it
40- * joins the product-wide feature surface (queried by `feature_name`), so
41- * no plugin prefix. `reason` is required on sad and bad, refused on ok.
39+ * The row carries `feature_name`, `error_code` on a sad or bad one, and
40+ * the entry's `props` beside them, checked as `log`'s are; it joins the
41+ * product-wide feature surface (queried by `feature_name`), so no plugin
42+ * prefix. `reason` is required on sad and bad, refused on ok.
4243 *
43- * @param entry the feature, how it went, and why when not ok
44+ * @param entry the feature, how it went, why when not ok, and the row's
45+ * properties by snake_case key
4446 * @example
4547 * await $.telemetry.mark({ feature: "learn_page", kind: "ok" })
4648 * await $.telemetry.mark({
4749 * feature: "learn_page",
50+ * kind: "ok",
51+ * props: { page: { value: "later", of: ["ready", "later"] } },
52+ * })
53+ * await $.telemetry.mark({
54+ * feature: "learn_page",
4855 * kind: "sad",
4956 * reason: "blocked",
5057 * })