Skip to content

Commit b0ef2bd

Browse files
Expand the anonymous product event catalog (#425)
* Batch telemetry events through a single queued transport Two upcoming event catalogs turn telemetry volume from one event per turn into one per tool call, which the previous transport would have answered with an unbounded set of concurrent per-event POSTs. Queueing behind one request bounds both sockets and memory when the endpoint is unreachable. * Document how telemetry batches and sheds events on the wire * Discard queued telemetry events when the user opts out Dropping the singleton on opt-out left the outgoing instance's batch timer armed, so events captured before the toggle would still reach the network afterwards. Opting out speaks to activity already generated, not only to activity still to come. * Expand the anonymous product event catalog Adds slash-command, skill, plugin, sub-agent, permission, compaction, and crash events to the telemetry catalog. Every identifier these events would naturally carry is named by someone other than us — an MCP server key is a settings key, a skill is a directory in the repo, a plugin id and an agent profile are author-chosen — so each is mapped to a fixed first-party enum at the emission site and reported as "custom" when it matches nothing. Emission takes Telemetry as an injected dependency rather than reading the process-wide handle, so a module built without one is silent by construction. * Report which provider rejected credentials on auth failure The auth_failure event reused error_class, which everywhere else means the JS error constructor name. One column carrying two incompatible meanings cannot be analysed, and it made the documented error_class guarantee false: the value shipped was a local send-failure kind that never passed through the classifier.
1 parent 093284f commit b0ef2bd

20 files changed

Lines changed: 1117 additions & 105 deletions

docs/TELEMETRY.md

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,26 @@ includes prompts, code, file contents, or paths.
66

77
## What's collected
88

9-
Three events, each with a small set of properties:
9+
Each event carries a small set of properties:
1010

1111
| Event | When | Properties |
1212
|---|---|---|
1313
| `cli_start` | Once per used session (see First-run disclosure) | (none beyond common properties) |
1414
| `session_end` | When a TUI session finishes | `status`, `turn_count`, `duration_ms`, `session_mode`, `exit_reason` |
1515
| `inference_turn` | Once per completed turn | `provider_id`, `model_id`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens`, `thinking_tokens`, `duration_ms` |
16+
| `slash_command` | A slash command is dispatched in the TUI | `command_name` |
17+
| `skill_used` | `use_skill` loads a skill that resolved | (none beyond common properties) |
18+
| `plugin_loaded` | A plugin is discovered and loaded at startup | `origin` |
19+
| `subagent_start` | A `task` dispatch begins | `agent_name` |
20+
| `subagent_end` | A `task` dispatch finishes | `agent_name`, `status`, `duration_ms` |
21+
| `permission_prompt` | An approval prompt is answered (or abandoned) | `decision`, `permission_kind` |
22+
| `compaction` | The compactor actually folds turns away | `mode`, `duration_ms`, `turns_before`, `turns_after` |
23+
| `crash` | A fatal error reaches the process-level handler | `kind`, `error_class` |
24+
| `auth_failure` | A provider rejects the stored credentials | `auth_provider` |
25+
26+
`compaction` is deliberately silent on the runs where the compactor decides
27+
there is nothing to compact — an event that also fires on no-ops makes its own
28+
duration and turn-count averages meaningless.
1629

1730
Common properties attached to every event: a random installation UUID
1831
(`distinct_id`), `session_id`, `service_version`, `os_type`, `os_arch`, and a
@@ -30,10 +43,45 @@ onboarding or settings. `model_id` is the model identifier exactly as
3043
configured — it is the one user-entered string that is sent, so do not put
3144
anything identifying in a model name.
3245

46+
## Names are never sent, only categories
47+
48+
Most of the things a usage event would naturally want to name are named by
49+
someone other than us: an MCP server key is a key in your settings, a skill is
50+
a directory in your repo, a plugin id is chosen by its author, an agent profile
51+
and a plugin's slash commands are project-local. On a private repo those names
52+
are your employer, your internal services, or fragments of your paths.
53+
54+
So none of them are transmitted. Each is matched against a fixed list of names
55+
this project itself ships and reported as that name, or as `custom` when it
56+
matches nothing — with `mcp` as its own bucket for `permission_kind`, so the
57+
share of prompts driven by MCP stays visible without the server key coming
58+
with it. `skill_used` and `plugin_loaded` go further: there is no first-party
59+
list of skills or plugins to match against, so `skill_used` carries no name at
60+
all and `plugin_loaded` carries only `origin`, the discovery tier
61+
(`repo`, `user`, `project`, `path`).
62+
63+
`error_class` is bucketed the same way: only the error types defined by the
64+
language are reported by name, because an error subclass defined in
65+
application or plugin code is as author-chosen as any other string. It appears
66+
on `crash` and nowhere else, so the column means one thing everywhere it is
67+
recorded.
68+
69+
`auth_provider` is a separate property for that reason: it names which
70+
provider's sign-in was rejected (`codex`, `xai`), chosen from a fixed
71+
first-party set in `src/tui-opentui/session-chrome.ts`. No part of the
72+
provider's rejection message is sent.
73+
74+
The mapping is `src/telemetry/classify.ts`, and the tests that feed each
75+
emission site a deliberately identifying name and assert it reaches no part of
76+
the payload are in `tests/unit/telemetry-product-events.test.ts`.
77+
3378
## What's never collected
3479

3580
- Prompts, model output, or any conversation content
3681
- File paths, file contents, or repo/project names
82+
- Names anyone but this project chose: MCP servers, skills, plugins, agent
83+
profiles, plugin-registered slash commands, error subclasses (see above)
84+
- Shell commands, tool arguments, or tool results
3785
- API keys, tokens, or any other credential
3886
- Anything not in the allowlist above
3987

@@ -46,6 +94,12 @@ Any of the following disables telemetry entirely:
4694
- `CORBITS_TELEMETRY` set to any falsy value: `0`, `false`, `off`, `no`, or empty
4795
- `DO_NOT_TRACK=1` (the standard [Console Do Not Track](https://consoledonottrack.com/) convention)
4896

97+
Turning telemetry off also discards whatever is still queued and unsent.
98+
Events captured earlier in the session but not yet transmitted are thrown
99+
away at the moment you opt out, not sent on the way out — opting out covers
100+
the activity you have already generated, not just the activity still to
101+
come.
102+
49103
Re-enable from the same Telemetry tab or by removing the env var / settings
50104
override. While an env kill is active the Telemetry tab cannot re-enable —
51105
the env override always wins, and the attempt is refused rather than
@@ -92,9 +146,30 @@ Events are sent to PostHog. PostHog derives an approximate country from the
92146
request IP server-side; the client sends no location data itself. No
93147
self-hosted or third-party analytics beyond PostHog are used.
94148

149+
## On the wire
150+
151+
Events are not sent one at a time. Each captured event is stamped with its
152+
capture time and held in an in-memory queue, which is posted to PostHog's
153+
`/batch/` endpoint when it reaches the batch size or when the batch
154+
interval elapses, whichever comes first. At most one request is ever in
155+
flight: events captured while a request is open wait for it rather than
156+
opening another connection. Exit paths flush the queue, bounded by a short
157+
deadline so a slow endpoint cannot delay quitting.
158+
159+
The queue has a hard depth limit. Once it is full — which in practice means
160+
the endpoint is unreachable, as on a captive portal or behind a hung proxy
161+
— the oldest queued events are dropped to make room for new ones. Telemetry
162+
is therefore lossy by design: it never grows memory without bound, never
163+
retries indefinitely, and never blocks or reports failures to the user.
164+
Nothing is written to disk, so dropped events are gone rather than deferred
165+
to a later run.
166+
167+
See `src/telemetry/index.ts` for the batch size, interval, and queue limit
168+
in force.
169+
95170
## Not this document
96171

97172
Local performance tracing and optional OpenTelemetry export to an operator-owned
98173
collector (Phoenix, PostHog OTEL, Jaeger, generic OTLP) are documented in
99-
`docs/PERFTRACE.md`. That pipe is separate: it does not expand these three
100-
events, and product telemetry opt-out does not control OTEL export.
174+
`docs/PERFTRACE.md`. That pipe is separate: it does not expand the events
175+
above, and product telemetry opt-out does not control OTEL export.

src/agent/tools.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type ShellTimeoutConfig,
1717
} from "../plugins/shell-guard-plugin.js";
1818
import { advertiseEditFileLineRange } from "../plugins/edit-file-line-range.js";
19+
import type { Telemetry } from "../telemetry/index.js";
1920
import type { PermissionGate } from "../permission/gate.js";
2021
import { buildCorePosixToolPlugins } from "./posix-tool-plugins.js";
2122
import { createLazyBlobReader } from "./lazy-blob-reader.js";
@@ -112,6 +113,9 @@ export type AgentToolsetArgs = {
112113
// Real sessions always pass their detected values — see tool-search.ts for
113114
// why these must be fixed for the session's life.
114115
toolAvailability?: ToolAvailability;
116+
// Records skill loads and sub-agent dispatch. Omitted (tests, ad-hoc
117+
// toolsets) means those events are never emitted.
118+
telemetry?: Telemetry;
115119
// When provided, the agent gets a `task` tool that delegates to autonomous
116120
// sub-agents. Omitted in contexts that cannot spawn sub-agents (e.g. tests).
117121
subAgent?: {
@@ -210,7 +214,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
210214
),
211215
})),
212216
createListDirTool(cwd),
213-
createUseSkillTool(cwd, skillDirs),
217+
createUseSkillTool(cwd, skillDirs, args.telemetry),
214218
createWebFetchTool(),
215219
createWebSearchTool(),
216220
...(subAgentsEnabled && args.subAgent !== undefined
@@ -237,6 +241,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
237241
...(args.subAgent.useWorktree !== undefined
238242
? { useWorktree: args.subAgent.useWorktree }
239243
: {}),
244+
...(args.telemetry !== undefined ? { telemetry: args.telemetry } : {}),
240245
}),
241246
...(args.subAgent.profiles !== undefined
242247
? [

src/agent/use-skill.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { ToolDefinition } from "@intx/types/runtime";
44
import { type } from "arktype";
55

66
import { resolveSkillBody } from "../extensions/skills.js";
7+
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
78

89
// Lazy skill loading: the available skills are listed by name + description in
910
// the system prompt, but their full instructions are pulled into context only
@@ -24,7 +25,11 @@ const useSkillDefinition: ToolDefinition = {
2425

2526
const UseSkillArgs = type({ name: "string" });
2627

27-
export function createUseSkillTool(cwd: string, skillDirs: string[] = []): AgentTool {
28+
export function createUseSkillTool(
29+
cwd: string,
30+
skillDirs: string[] = [],
31+
telemetry: Telemetry = NOOP_TELEMETRY,
32+
): AgentTool {
2833
return stringTool({
2934
definition: useSkillDefinition,
3035
handler: async (rawArgs: Record<string, unknown>): Promise<string> => {
@@ -34,6 +39,10 @@ export function createUseSkillTool(cwd: string, skillDirs: string[] = []): Agent
3439
if (name.length === 0) return "Error: use_skill requires a non-empty name.";
3540
const body = await resolveSkillBody(cwd, name, skillDirs);
3641
if (body === undefined) return `No skill named "${name}" is available.`;
42+
// Skills are project- or plugin-authored, so the name is as identifying
43+
// as any other user-written string and never leaves the process; the
44+
// event records only that a skill was loaded.
45+
telemetry.capture("skill_used");
3746
return `Skill "${name}" — follow these instructions for this task:\n\n${body}`;
3847
},
3948
});

src/exec/runner.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import type {
5353
PermissionRequest,
5454
} from "../permission/types.js";
5555
import { createAgentToolset, type AgentToolset, type OperatorResult } from "../agent/tools.js";
56+
import { liveTelemetry } from "../telemetry/singleton.js";
5657
import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js";
5758
import {
5859
expandExistingPluginMembers,
@@ -262,6 +263,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
262263
isProjectPluginTrusted,
263264
isRegisteredPathTrusted,
264265
diagnostics: pluginLoadDiag,
266+
telemetry: liveTelemetry,
265267
});
266268
emitPluginWarningSummary(pluginLoadDiag, (line) => logger.warn(line));
267269
// Metadata-only (untrusted) modules stay out of executable plugins.
@@ -298,6 +300,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
298300

299301
const permissionGate = createPermissionGate({
300302
approvals: seededApprovals,
303+
telemetry: liveTelemetry,
301304
cwd: config.cwd,
302305
rootsProvider: createWorktreeRootsProvider(config.cwd),
303306
providerName: config.providerName,
@@ -326,6 +329,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
326329
cwd: config.cwd,
327330
permissionGate,
328331
skillDirs,
332+
telemetry: liveTelemetry,
329333
...(shellTimeout !== undefined ? { shellTimeout } : {}),
330334
...(toolWatchdog !== undefined ? { toolWatchdog } : {}),
331335
...(localSettingsForMode?.env !== undefined ? { shellEnv: localSettingsForMode.env } : {}),
@@ -544,6 +548,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
544548
"pruning-compactor": createSessionPruningCompactor({
545549
compactionMode: liveCompactionMode,
546550
summarize: summarizeForCompaction,
551+
telemetry: liveTelemetry,
547552
}),
548553
},
549554
});

src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ensureTelemetrySettings, globalSettingsPath } from "./config/settings.j
99
import { installFileLogSink } from "./logging/sink.js";
1010
import { flushPerfToOtel } from "./perf/index.js";
1111
import { createTelemetry, telemetryDisabledByEnv } from "./telemetry/index.js";
12+
import { classifyErrorClass } from "./telemetry/classify.js";
1213
import { getTelemetry, setTelemetry } from "./telemetry/singleton.js";
1314
import { runExec } from "./exec/runner.js";
1415
import { runOnboarding } from "./tui/onboarding.js";
@@ -154,6 +155,12 @@ export async function handleFatal(kind: CrashKind, error: unknown): Promise<void
154155
process.stderr.write("failed to write crash report\n");
155156
}
156157
await finalizeActiveRunOnCrash(error);
158+
// kind is one of the two process-level handler names. A constructor name is
159+
// author-chosen — an application or plugin error subclass can be as
160+
// identifying as any other free text — so only the language's own error
161+
// types are reported by name.
162+
getTelemetry().capture("crash", { kind, error_class: classifyErrorClass(error) });
163+
await getTelemetry().flush();
157164
process.exit(1);
158165
}
159166

src/permission/gate.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,25 @@ import {
2828
import type { MCPClient } from "../mcp/client.js";
2929
import { end, start } from "../perf/index.js";
3030
import { currentTurnId } from "../perf/reactor-spans.js";
31+
import { classifyPermissionKind } from "../telemetry/classify.js";
32+
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
33+
34+
// Closes out an operator prompt: ends the wait span and records the outcome.
35+
// buildRequests yields at most one request per tool call, and the two prompt
36+
// sites below are mutually exclusive, so this runs once per prompt shown.
37+
function finishApprovalWait(
38+
telemetry: Telemetry,
39+
waitSpanId: string,
40+
tool: string,
41+
outcome: ApprovalOutcome | undefined,
42+
): void {
43+
const decision = outcome !== undefined && outcome.allow ? "allow" : "deny";
44+
end(waitSpanId, outcome !== undefined ? { decision } : undefined);
45+
telemetry.capture("permission_prompt", {
46+
decision,
47+
permission_kind: classifyPermissionKind(tool),
48+
});
49+
}
3150

3251
export type GateVerdict = { allowed: true } | { allowed: false; reason: string };
3352

@@ -211,6 +230,10 @@ export type PermissionGateOptions = {
211230
// restriction anchored to the session cwd; a caller resolving a sub-agent
212231
// request's own cwd would clear restrictions the gate still enforces.
213232
onGrant?: (approval: Approval, covers: (request: PermissionRequest) => boolean) => void;
233+
// Records that a prompt was shown and how it was answered. Injected rather
234+
// than read from the process-wide handle so a gate built without one is
235+
// silent by construction.
236+
telemetry?: Telemetry;
214237
};
215238

216239
export type PermissionGate = {
@@ -246,6 +269,7 @@ export type PermissionGate = {
246269

247270
export function createPermissionGate(options: PermissionGateOptions): PermissionGate {
248271
const { requestApproval, persist, interactive, skipPermissions, providerName, model, cwd } = options;
272+
const telemetry = options.telemetry ?? NOOP_TELEMETRY;
249273
const mcpTiers = options.mcpTiers ?? createMcpToolPermissionRegistry();
250274
const resolvedCwd = cwd ?? process.cwd();
251275
const rootsProvider = options.rootsProvider ?? createWorktreeRootsProvider(resolvedCwd);
@@ -463,12 +487,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
463487
try {
464488
outcome = await requestApproval(requestForOperator);
465489
} finally {
466-
end(
467-
waitSpanId,
468-
outcome !== undefined
469-
? { decision: outcome.allow ? "allow" : "deny" }
470-
: undefined,
471-
);
490+
finishApprovalWait(telemetry, waitSpanId, request.tool, outcome);
472491
}
473492
if (outcome === undefined || !outcome.allow) {
474493
const suffix =
@@ -516,12 +535,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
516535
try {
517536
outcome = await requestApproval(request);
518537
} finally {
519-
end(
520-
waitSpanId,
521-
outcome !== undefined
522-
? { decision: outcome.allow ? "allow" : "deny" }
523-
: undefined,
524-
);
538+
finishApprovalWait(telemetry, waitSpanId, request.tool, outcome);
525539
}
526540
if (outcome === undefined || !outcome.allow) {
527541
const suffix =

0 commit comments

Comments
 (0)