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
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * What branch mode resolved its base to, as a base-resolve mark names it, | |
| 3 | + * the built-in panel's own: the merge base, HEAD itself, or nothing. | |
| 4 | + */ | |
| 5 | +export const BASE_OUTCOMES = ['merge-base', 'head-is-base', 'none'] as const |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './base-outcomes.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -2,8 +2,10 @@ | ||
| 2 | 2 | * The feature names the plugin marks, kept as the built-in panel's so the |
| 3 | 3 | * feature surface keeps one series across the swap. |
| 4 | 4 | * |
| 5 | - * `read` per fetch round, `tabSwitch` per open or close, `baseSwitch` per | |
| 6 | - * base pick, `baseResolve` once a session, `selectionAttach` per diff asked. | |
| 5 | + * `read` per fetch round, `tabSwitch` per `/diff` open or close and per | |
| 6 | + * close by the pane's own control, `baseSwitch` per base pick, `baseResolve` | |
| 7 | + * on the first resolve and the first failure, `selectionAttach` per diff | |
| 8 | + * asked. | |
| 7 | 9 | */ |
| 8 | 10 | export const FEATURES = { |
| 9 | 11 | read: 'repl_diff_read', |
| @@ -1,9 +1,11 @@ | ||
| 1 | +export * from './base-outcomes' | |
| 1 | 2 | export * from './features' |
| 2 | 3 | export * from './mark-outcome' |
| 3 | 4 | export * from './recorder' |
| 4 | 5 | export * from './recorder-of.js' |
| 5 | 6 | export * from './safely' |
| 6 | 7 | export * from './shown-triggers' |
| 8 | +export * from './tabs' | |
| 7 | 9 | export * from './width-bucket-of' |
| 8 | 10 | export * from './width-buckets' |
| 9 | 11 |
| @@ -1,5 +1,12 @@ | ||
| 1 | +import type { Host } from '../../host' | |
| 2 | + | |
| 1 | 3 | /** |
| 2 | - * How a marked feature went: fine, or degraded for a reason (the pane never | |
| 3 | - * marks `bad`: a failed fetch keeps the last good diff on screen). | |
| 4 | + * How a marked feature went: fine, or degraded for a reason, with the | |
| 5 | + * properties the built-in panel's mark carries beside it. | |
| 6 | + * | |
| 7 | + * The pane never marks `bad`: a failed fetch keeps the last good diff on | |
| 8 | + * screen. | |
| 4 | 9 | */ |
| 5 | -export type MarkOutcome = { kind: 'ok' } | { kind: 'sad'; reason: string } | |
| 10 | +export type MarkOutcome = ({ kind: 'ok' } | { kind: 'sad'; reason: string }) & { | |
| 11 | + props?: Parameters<Host['mark']>[0]['props'] | |
| 12 | +} |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export * from './tabs.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,5 @@ | ||
| 1 | +/** | |
| 2 | + * Where a tab-switch mark says the person went, named as the built-in | |
| 3 | + * panel names it: the diff, or the conversation it closes back to. | |
| 4 | + */ | |
| 5 | +export const TABS = ['diff', 'convo'] as const |
| @@ -61,7 +61,12 @@ | ||
| 61 | 61 | const isError = base.kind === 'error' |
| 62 | 62 | const outcome: Record.MarkOutcome = isError |
| 63 | 63 | ? { kind: 'sad', reason: base.reason } |
| 64 | - : { kind: 'ok' } | |
| 64 | + : { | |
| 65 | + kind: 'ok', | |
| 66 | + props: { | |
| 67 | + outcome: { value: base.kind, of: Record.BASE_OUTCOMES }, | |
| 68 | + }, | |
| 69 | + } | |
| 65 | 70 | |
| 66 | 71 | if (!loggedBaseKinds.has(outcome.kind)) { |
| 67 | 72 | loggedBaseKinds.add(outcome.kind) |
| @@ -148,7 +153,7 @@ | ||
| 148 | 153 | model.selectedPath, |
| 149 | 154 | ) |
| 150 | 155 | |
| 151 | - async function loadBody(engine: Host): Promise<void> { | |
| 156 | + async function loadBody(engine: Host): Promise<boolean> { | |
| 152 | 157 | const { data } = model |
| 153 | 158 | const selected = selectedOf() |
| 154 | 159 | |
| @@ -156,13 +161,13 @@ | ||
| 156 | 161 | bodyKey = null |
| 157 | 162 | model = { ...model, body: null, bodyState: 'idle' } |
| 158 | 163 | |
| 159 | - return | |
| 164 | + return false | |
| 160 | 165 | } |
| 161 | 166 | |
| 162 | 167 | const key = `${generation}|${data.baseRef}|${selected.path}` |
| 163 | 168 | |
| 164 | 169 | if (key === bodyKey) { |
| 165 | - return | |
| 170 | + return false | |
| 166 | 171 | } |
| 167 | 172 | |
| 168 | 173 | bodyKey = key |
| @@ -171,11 +176,13 @@ | ||
| 171 | 176 | const body = await backend.fetchFileHunks(data, selected) |
| 172 | 177 | |
| 173 | 178 | if (bodyKey !== key) { |
| 174 | - return | |
| 179 | + return body === null | |
| 175 | 180 | } |
| 176 | 181 | |
| 177 | 182 | model = { ...model, body, bodyState: body ? 'ready' : 'failed' } |
| 178 | 183 | redraw(engine) |
| 184 | + | |
| 185 | + return body === null | |
| 179 | 186 | } |
| 180 | 187 | |
| 181 | 188 | function startPoll(engine: Host, pinned: Backend.Backend) { |
| @@ -240,7 +247,6 @@ | ||
| 240 | 247 | }) |
| 241 | 248 | break |
| 242 | 249 | case 'data': |
| 243 | - record.mark(Record.FEATURES.read, { kind: 'ok' }) | |
| 244 | 250 | generation += 1 |
| 245 | 251 | |
| 246 | 252 | if (pinned) { |
| @@ -250,7 +256,23 @@ | ||
| 250 | 256 | break |
| 251 | 257 | } |
| 252 | 258 | |
| 253 | - await loadBody(engine) | |
| 259 | + const hasHunksFailed = await loadBody(engine) | |
| 260 | + | |
| 261 | + if (outcome.kind === 'data') { | |
| 262 | + record.mark( | |
| 263 | + Record.FEATURES.read, | |
| 264 | + hasHunksFailed | |
| 265 | + ? { kind: 'sad', reason: 'git_hunks_failed' } | |
| 266 | + : { kind: 'ok' }, | |
| 267 | + ) | |
| 268 | + } | |
| 269 | + } catch (error) { | |
| 270 | + record.mark(Record.FEATURES.read, { | |
| 271 | + kind: 'sad', | |
| 272 | + reason: 'git_diff_threw', | |
| 273 | + }) | |
| 274 | + | |
| 275 | + throw error | |
| 254 | 276 | } finally { |
| 255 | 277 | isRefreshing = false |
| 256 | 278 | redraw(engine) |
| @@ -281,13 +303,11 @@ | ||
| 281 | 303 | const isManual = trigger === 'manual' |
| 282 | 304 | await engine.openPane(isManual ? { ...pane, ...Names.FOCUSED_PANE } : pane) |
| 283 | 305 | isPaneOpen = true |
| 284 | - const record = Record.recorderOf(engine) | |
| 285 | - record.mark(Record.FEATURES.tabSwitch, { kind: 'ok' }) | |
| 286 | 306 | const sessionId = await engine.sessionId().catch(() => null) |
| 287 | 307 | |
| 288 | 308 | if (sessionId !== null && sessionId !== shownSessionId) { |
| 289 | 309 | shownSessionId = sessionId |
| 290 | - record.shown(trigger, Record.widthBucketOf(columns)) | |
| 310 | + Record.recorderOf(engine).shown(trigger, Record.widthBucketOf(columns)) | |
| 291 | 311 | } |
| 292 | 312 | |
| 293 | 313 | void refresh(engine) |
| @@ -296,9 +316,15 @@ | ||
| 296 | 316 | async function closePane(engine: Host): Promise<void> { |
| 297 | 317 | await engine.closePane({ id: Names.PANE_ID }) |
| 298 | 318 | isPaneOpen = false |
| 299 | - Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { kind: 'ok' }) | |
| 300 | 319 | } |
| 301 | 320 | |
| 321 | + function markTabSwitch(engine: Host, tab: (typeof Record.TABS)[number]) { | |
| 322 | + Record.recorderOf(engine).mark(Record.FEATURES.tabSwitch, { | |
| 323 | + kind: 'ok', | |
| 324 | + props: { tab: { value: tab, of: Record.TABS } }, | |
| 325 | + }) | |
| 326 | + } | |
| 327 | + | |
| 302 | 328 | async function wasDrawnWhenProbed(engine: Host): Promise<boolean> { |
| 303 | 329 | wasDrawnSinceProbe = false |
| 304 | 330 | engine.invalidate() |
| @@ -367,6 +393,7 @@ | ||
| 367 | 393 | model = { ...model, requestedMode: mode, body: null, bodyState: 'idle' } |
| 368 | 394 | Record.recorderOf(engine).mark(Record.FEATURES.baseSwitch, { |
| 369 | 395 | kind: 'ok', |
| 396 | + props: { mode: { value: mode, of: model.baseModes } }, | |
| 370 | 397 | }) |
| 371 | 398 | const toplevel = model.data?.repository.toplevel |
| 372 | 399 | |
| @@ -401,6 +428,7 @@ | ||
| 401 | 428 | }, |
| 402 | 429 | close: () => { |
| 403 | 430 | void closePane(engine) |
| 431 | + .then(() => markTabSwitch(engine, 'convo')) | |
| 404 | 432 | .then(() => engine.storeSet(Names.STORE_OPEN_KEY, false)) |
| 405 | 433 | .catch(() => undefined) |
| 406 | 434 | }, |
| @@ -525,6 +553,7 @@ | ||
| 525 | 553 | |
| 526 | 554 | const isOpening = toggle === 'open' |
| 527 | 555 | await (isOpening ? openPane(host, 'manual') : closePane(host)) |
| 556 | + markTabSwitch(host, isOpening ? 'diff' : 'convo') | |
| 528 | 557 | await host.storeSet(Names.STORE_OPEN_KEY, isOpening).catch(() => undefined) |
| 529 | 558 | |
| 530 | 559 | return {} |
| @@ -34,13 +34,14 @@ | ||
| 34 | 34 | } |
| 35 | 35 | |
| 36 | 36 | /** |
| 37 | - * What `$.telemetry.mark` takes: the feature, how it went, and why when | |
| 38 | - * not ok. | |
| 37 | + * What `$.telemetry.mark` takes: the feature, how it went, why when not | |
| 38 | + * ok, and the properties the row carries beside them by snake_case key. | |
| 39 | 39 | */ |
| 40 | 40 | type DiffTelemetryMarkEntry = { |
| 41 | 41 | feature: string |
| 42 | 42 | kind: DiffTelemetryMarkKind |
| 43 | 43 | reason?: string |
| 44 | + props?: Readonly<Record<string, DiffTelemetryProp>> | |
| 44 | 45 | } |
| 45 | 46 | |
| 46 | 47 | interface EngineInterface { |
| @@ -1,15 +1,16 @@ | ||
| 1 | 1 | # telemetry |
| 2 | 2 | |
| 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 | |
| 5 | 5 | `$.session` and `$.http` nouns beneath. `$.telemetry.log({ event, props })` |
| 6 | 6 | 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. | |
| 13 | 14 | |
| 14 | 15 | It sends nothing wherever the CLI's own analytics are off: under |
| 15 | 16 | `DISABLE_TELEMETRY`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or |
| @@ -22,9 +23,10 @@ | ||
| 22 | 23 | |
| 23 | 24 | Nothing free-form reaches a row. An event name and every property key is a |
| 24 | 25 | 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. | |
| 28 | 30 | |
| 29 | 31 | `hooks/register.ts` is the module; `hooks/telemetry-types/` is the noun's |
| 30 | 32 | type as a caller sees it. |
| @@ -1,8 +1,7 @@ | ||
| 1 | -import { checkedValue } from './checked-value' | |
| 1 | +import { checkedProps } from './checked-props' | |
| 2 | 2 | import type { Fields } from './fields' |
| 3 | 3 | import { fieldsOf } from './fields-of' |
| 4 | 4 | import { isRecord } from './is-record' |
| 5 | -import { PROP_LIMIT } from './prop-limit' | |
| 6 | 5 | import { refusal } from './refusal' |
| 7 | 6 | import { TOKEN } from './token' |
| 8 | 7 | |
| @@ -27,25 +26,5 @@ | ||
| 27 | 26 | throw refusal('takes an event name, a snake_case token') |
| 28 | 27 | } |
| 29 | 28 | |
| 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')) | |
| 51 | 30 | } |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { checkedProps } from './checked-props' | |
| 1 | 2 | import type { Fields } from './fields' |
| 2 | 3 | import { isMarkKind } from './is-mark-kind' |
| 3 | 4 | import { isRecord } from './is-record' |
| @@ -7,17 +8,20 @@ | ||
| 7 | 8 | |
| 8 | 9 | /** |
| 9 | 10 | * 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 props. | |
| 11 | 12 | * |
| 12 | - * @param entry the feature, kind and reason as the caller passed them | |
| 13 | + * Each property is checked in turn, as a log's are. | |
| 14 | + * | |
| 15 | + * @param entry the feature, kind, reason and properties as the caller passed | |
| 16 | + * them | |
| 13 | 17 | * @returns the mark's row fields once the entry passes every check |
| 14 | 18 | */ |
| 15 | 19 | export function checkedMark(entry: unknown): Fields { |
| 16 | 20 | if (!isRecord(entry)) { |
| 17 | - throw refusal('takes one entry, { feature, kind, reason? }', 'mark') | |
| 21 | + throw refusal('takes one entry, { feature, kind, reason?, props? }', 'mark') | |
| 18 | 22 | } |
| 19 | 23 | |
| 20 | - const { feature, kind, reason } = entry | |
| 24 | + const { feature, kind, reason, props = {} } = entry | |
| 21 | 25 | |
| 22 | 26 | if (typeof feature !== 'string' || !TOKEN.test(feature)) { |
| 23 | 27 | throw refusal('takes a feature name, a snake_case token', 'mark') |
| @@ -32,7 +36,7 @@ | ||
| 32 | 36 | throw refusal('reason: an ok mark carries none', 'mark') |
| 33 | 37 | } |
| 34 | 38 | |
| 35 | - return markFieldsOf(kind, feature) | |
| 39 | + return markFieldsOf({ kind, feature, props: checkedProps(props, 'mark') }) | |
| 36 | 40 | } |
| 37 | 41 | |
| 38 | 42 | if (typeof reason !== 'string' || !TOKEN.test(reason)) { |
| @@ -42,5 +46,10 @@ | ||
| 42 | 46 | ) |
| 43 | 47 | } |
| 44 | 48 | |
| 45 | - return markFieldsOf(kind, feature, reason) | |
| 49 | + return markFieldsOf({ | |
| 50 | + kind, | |
| 51 | + feature, | |
| 52 | + reason, | |
| 53 | + props: checkedProps(props, 'mark'), | |
| 54 | + }) | |
| 46 | 55 | } |
| @@ -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' | |
| 1 | 2 | import { CHOICE_TOKEN } from '../choice-token' |
| 2 | 3 | import { CHOICES_LIMIT } from '../choices-limit' |
| 3 | 4 | import { isRecord } from '../is-record' |
| @@ -11,12 +12,14 @@ | ||
| 11 | 12 | * |
| 12 | 13 | * @param key the property's key, named in a refusal |
| 13 | 14 | * @param value what the caller passed under it |
| 15 | + * @param method the method the property was passed to, named in a refusal | |
| 14 | 16 | * @returns the value as stored: the boolean or finite number unchanged, or the |
| 15 | 17 | * chosen member when value is a Choice |
| 16 | 18 | */ |
| 17 | 19 | export function checkedValue( |
| 18 | 20 | key: string, |
| 19 | 21 | value: unknown, |
| 22 | + method: TelemetryTypes.Method = 'log', | |
| 20 | 23 | ): string | number | boolean { |
| 21 | 24 | if (typeof value === 'boolean') { |
| 22 | 25 | return value |
| @@ -27,13 +30,14 @@ | ||
| 27 | 30 | return value |
| 28 | 31 | } |
| 29 | 32 | |
| 30 | - throw refusal(`props.${key}: a number is finite`) | |
| 33 | + throw refusal(`props.${key}: a number is finite`, method) | |
| 31 | 34 | } |
| 32 | 35 | |
| 33 | 36 | if (typeof value === 'string') { |
| 34 | 37 | throw refusal( |
| 35 | 38 | `props.${key}: free text is refused; a string is a Choice, ` + |
| 36 | 39 | `{ value, of: [...] }`, |
| 40 | + method, | |
| 37 | 41 | ) |
| 38 | 42 | } |
| 39 | 43 | |
| @@ -41,6 +45,7 @@ | ||
| 41 | 45 | throw refusal( |
| 42 | 46 | `props.${key}: a value is a finite number, a boolean, or a Choice, ` + |
| 43 | 47 | `{ value, of: [...] }`, |
| 48 | + method, | |
| 44 | 49 | ) |
| 45 | 50 | } |
| 46 | 51 | |
| @@ -55,12 +60,13 @@ | ||
| 55 | 60 | |
| 56 | 61 | if (!isTokenList) { |
| 57 | 62 | throw refusal( |
| 58 | - `props.${key}.of: a list of 1 to ${CHOICES_LIMIT} ` + `snake_case tokens`, | |
| 63 | + `props.${key}.of: a list of 1 to ${CHOICES_LIMIT} ` + `lowercase tokens`, | |
| 64 | + method, | |
| 59 | 65 | ) |
| 60 | 66 | } |
| 61 | 67 | |
| 62 | 68 | 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) | |
| 64 | 70 | } |
| 65 | 71 | |
| 66 | 72 | return chosen |
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | /** |
| 2 | - * The shape of a Choice member: a snake_case token of at most 64 | |
| 3 | - * characters. | |
| 2 | + * The shape of a Choice member: a lowercase token of at most 64 characters, | |
| 3 | + * of letters, digits, `_` and `-`. | |
| 4 | 4 | * |
| 5 | 5 | * Unlike a name or a key (TOKEN), it may start with a digit (a bucket such |
| 6 | - * as `110_to_143`). | |
| 6 | + * as `110_to_143`) and carry a hyphen (a kind such as `merge-base`). | |
| 7 | 7 | */ |
| 8 | -export const CHOICE_TOKEN = /^[a-z0-9][a-z0-9_]{0,63}$/ | |
| 8 | +export const CHOICE_TOKEN = /^[a-z0-9][a-z0-9_-]{0,63}$/ |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | export * from './batch-of.js' |
| 2 | 2 | export * from './checked-fields.js' |
| 3 | 3 | export * from './checked-mark.js' |
| 4 | +export * from './checked-props' | |
| 4 | 5 | export * from './checked-value' |
| 5 | 6 | export * from './choice-token' |
| 6 | 7 | export * from './choices-limit' |
| @@ -12,6 +13,7 @@ | ||
| 12 | 13 | export * from './ingest-url.js' |
| 13 | 14 | export * from './is-mark-kind' |
| 14 | 15 | export * from './is-record' |
| 16 | +export * from './mark' | |
| 15 | 17 | export * from './mark-fields-of' |
| 16 | 18 | export * from './mark-kinds' |
| 17 | 19 | export * from './prop-limit' |
| @@ -1,0 +1,3 @@ | ||
| 1 | +export type * from './mark.js' | |
| 2 | + | |
| 3 | +export * as default from '.' |
| @@ -1,0 +1,13 @@ | ||
| 1 | +import type TelemetryTypes from '../../telemetry-types' | |
| 2 | +import type { Fields } from '../fields' | |
| 3 | + | |
| 4 | +/** | |
| 5 | + * One mark past every check: the feature, how it went, why when not ok, | |
| 6 | + * and its properties as they go into the row. | |
| 7 | + */ | |
| 8 | +export type Mark = { | |
| 9 | + kind: TelemetryTypes.MarkKind | |
| 10 | + feature: string | |
| 11 | + reason?: string | |
| 12 | + props: Fields['props'] | |
| 13 | +} |
| @@ -1,24 +1,28 @@ | ||
| 1 | -import type TelemetryTypes from '../../telemetry-types' | |
| 2 | 1 | import { FEATURE_PREFIX } from '../feature-prefix' |
| 3 | 2 | import type { Fields } from '../fields' |
| 3 | +import type { Mark } from '../mark' | |
| 4 | 4 | |
| 5 | 5 | /** |
| 6 | 6 | * One checked mark as its fields: the CLI's own feature event, |
| 7 | 7 | * `tengu_feature_<kind>` with `feature_name`, and `error_code` when given. |
| 8 | 8 | * |
| 9 | - * @param kind how the feature went | |
| 10 | - * @param feature the feature marked | |
| 11 | - * @param reason why, on a sad or bad mark; absent on ok | |
| 9 | + * The mark's properties merge in as the CLI's feature events merge their | |
| 10 | + * extras: after `feature_name` on an ok row, and beneath `feature_name` | |
| 11 | + * and `error_code` on a sad or bad one. | |
| 12 | + * | |
| 13 | + * @param mark the feature, how it went, why when not ok, and its checked | |
| 14 | + * properties | |
| 12 | 15 | * @returns the event's name and props, ready to log |
| 13 | 16 | */ |
| 14 | -export const markFieldsOf = ( | |
| 15 | - kind: TelemetryTypes.MarkKind, | |
| 16 | - feature: string, | |
| 17 | - reason?: string, | |
| 18 | -): Fields => ({ | |
| 17 | +export const markFieldsOf = ({ | |
| 18 | + kind, | |
| 19 | + feature, | |
| 20 | + reason, | |
| 21 | + props, | |
| 22 | +}: Mark): Fields => ({ | |
| 19 | 23 | name: FEATURE_PREFIX + kind, |
| 20 | 24 | props: |
| 21 | 25 | reason === undefined |
| 22 | - ? { feature_name: feature } | |
| 23 | - : { feature_name: feature, error_code: reason }, | |
| 26 | + ? { feature_name: feature, ...props } | |
| 27 | + : { ...props, feature_name: feature, error_code: reason }, | |
| 24 | 28 | }) |
| @@ -2,7 +2,8 @@ | ||
| 2 | 2 | * A string property: the value and the list it is chosen from, declared |
| 3 | 3 | * beside it, so no free text reaches the row. |
| 4 | 4 | * |
| 5 | - * Every member of `of` is a snake_case token, which may start with a digit, | |
| 6 | - * at most CHOICES_LIMIT of them; `value` is one of them. | |
| 5 | + * Every member of `of` is a lowercase token of letters, digits, `_` and | |
| 6 | + * `-`, which may start with a digit, at most CHOICES_LIMIT of them; `value` | |
| 7 | + * is one of them. | |
| 7 | 8 | */ |
| 8 | 9 | export type Choice = { value: string; of: readonly string[] } |
| @@ -1,11 +1,13 @@ | ||
| 1 | 1 | import type { MarkKind } from '../mark-kind' |
| 2 | +import type { Prop } from '../prop' | |
| 2 | 3 | |
| 3 | 4 | /** |
| 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. | |
| 6 | 7 | */ |
| 7 | 8 | export type MarkEntry = { |
| 8 | 9 | feature: string |
| 9 | 10 | kind: MarkKind |
| 10 | 11 | reason?: string |
| 12 | + props?: Readonly<Record<string, Prop>> | |
| 11 | 13 | } |
| @@ -34,15 +34,21 @@ | ||
| 34 | 34 | * Marks one use of a feature as the CLI's own feature events do, one |
| 35 | 35 | * `tengu_feature_<kind>` row; resolves once the ingest accepted it. |
| 36 | 36 | * |
| 37 | - * The row carries `feature_name`, and `error_code` on a sad or bad one; it | |
| 38 | - * joins the product-wide feature surface (queried by `feature_name`), so | |
| 39 | - * no plugin prefix. `reason` is required on sad and bad, refused on ok. | |
| 37 | + * The row carries `feature_name`, `error_code` on sad or bad (`reason`, | |
| 38 | + * required there and refused on ok) and the entry's `props`, checked as | |
| 39 | + * `log`'s are; it joins the product-wide feature surface, so no prefix. | |
| 40 | 40 | * |
| 41 | - * @param entry the feature, how it went, and why when not ok | |
| 41 | + * @param entry the feature, how it went, why when not ok, and the row's | |
| 42 | + * properties by snake_case key | |
| 42 | 43 | * @example |
| 43 | 44 | * await $.telemetry.mark({ feature: "learn_page", kind: "ok" }) |
| 44 | 45 | * await $.telemetry.mark({ |
| 45 | 46 | * feature: "learn_page", |
| 47 | + * kind: "ok", | |
| 48 | + * props: { page: { value: "later", of: ["ready", "later"] } }, | |
| 49 | + * }) | |
| 50 | + * await $.telemetry.mark({ | |
| 51 | + * feature: "learn_page", | |
| 46 | 52 | * kind: "sad", |
| 47 | 53 | * reason: "blocked", |
| 48 | 54 | * }) |