Skip to content

Commit 8dfc5aa

Browse files
committed
mods/telemetry: a mark carries props, checked as a log's are and merged as the CLI's feature marks merge their extras
1 parent f6cd2ca commit 8dfc5aa

10 files changed

Lines changed: 100 additions & 51 deletions

File tree

‎mods/telemetry/README.md‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -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 @@ to a third-party provider or a cloud gateway sends nothing more. The row's
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.
Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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 @@ export function checkedFields(entry: unknown): Fields {
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
}

‎mods/telemetry/hooks/entries/checked-mark.ts‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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 @@ import { TOKEN } from './token'
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 @@ export function checkedMark(entry: unknown): Fields {
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 @@ export function checkedMark(entry: unknown): Fields {
4245
)
4346
}
4447

45-
return markFieldsOf(kind, feature, reason)
48+
return markFieldsOf(kind, feature, reason, checkedProps(props, 'mark'))
4649
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,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+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './checked-props.js'
2+
3+
export * as default from '.'

‎mods/telemetry/hooks/entries/checked-value/checked-value.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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 @@ import { refusal } from '../refusal'
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,20 +30,22 @@ export function checkedValue(
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

4044
if (!isRecord(value) || !Array.isArray(value.of)) {
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 @@ export function checkedValue(
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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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'

‎mods/telemetry/hooks/entries/mark-fields-of/mark-fields-of.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,26 @@ import type { Fields } from '../fields'
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
})
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
}

‎mods/telemetry/hooks/telemetry-types/telemetry.ts‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,22 @@ export type Telemetry = {
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
* })

0 commit comments

Comments
 (0)