@velum-labs/routekit-telemetry-core 0.16.4 → 0.16.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,11 +1,17 @@
1
+ export declare const TELEMETRY_CATEGORIES: readonly ["usage", "reliability", "adoption"];
2
+ export type TelemetryCategory = (typeof TELEMETRY_CATEGORIES)[number];
3
+ export type TelemetryCategories = Record<TelemetryCategory, boolean>;
4
+ export declare const DEFAULT_TELEMETRY_CATEGORIES: Readonly<TelemetryCategories>;
1
5
  export type ConsentFile = {
2
6
  enabled: boolean;
7
+ categories?: Partial<TelemetryCategories>;
3
8
  installId?: string;
4
9
  decidedAt?: string;
5
10
  };
6
11
  export type ConsentDecision = {
7
12
  enabled: boolean;
8
13
  source: "do-not-track" | "env" | "config" | "default";
14
+ categories: TelemetryCategories;
9
15
  installId?: string;
10
16
  };
11
17
  export type ConsentOptions = {
@@ -15,28 +21,373 @@ export type ConsentOptions = {
15
21
  now?: () => Date;
16
22
  randomId?: () => string;
17
23
  };
18
- /** Fields shared by every CLI's anonymous command event. */
24
+ /** @deprecated Use the event schema inventory instead. */
19
25
  export declare const CLI_COMMAND_TELEMETRY_FIELDS: readonly ["command", "cli_version", "os", "arch", "node_major", "duration_bucket", "exit_kind", "is_ci"];
20
26
  export type TelemetryFieldMap = Readonly<Record<string, readonly string[]>>;
21
- /**
22
- * Shared machine-readable consent status. Products may add operational fields
23
- * and render this metadata differently, but consent semantics stay identical.
24
- */
25
- export declare function telemetryStatusMetadata(decision: ConsentDecision, fields: TelemetryFieldMap): {
27
+ export type TelemetryDestination = {
28
+ provider: "posthog";
29
+ host: string;
30
+ configured: boolean;
31
+ };
32
+ export type TelemetryStatus = {
26
33
  enabled: boolean;
27
34
  source: ConsentDecision["source"];
28
- installId: string | null;
29
- fields: TelemetryFieldMap;
35
+ categories: TelemetryCategories;
36
+ installIdPresent: boolean;
37
+ destination: TelemetryDestination;
38
+ schema: TelemetrySchemaInventory;
30
39
  };
31
40
  export declare function createConsentManager(options: ConsentOptions): {
32
41
  path: () => string;
33
42
  read: () => ConsentFile | undefined;
34
43
  resolve: (env?: NodeJS.ProcessEnv) => ConsentDecision;
35
44
  enable(): ConsentFile;
36
- disable(): void;
45
+ disable(): ConsentFile;
46
+ setCategory(category: TelemetryCategory, enabled: boolean): ConsentFile;
47
+ resetIdentity(env?: NodeJS.ProcessEnv): ConsentFile | undefined;
37
48
  clear(): void;
38
49
  };
39
- export declare function durationBucket(ms: number): string;
50
+ export declare const DURATION_BUCKETS: readonly ["<1s", "1-10s", "10-60s", "1-5m", "5-30m", ">30m"];
51
+ export declare function durationBucket(ms: number): (typeof DURATION_BUCKETS)[number];
52
+ export declare const TELEMETRY_OUTCOMES: readonly ["success", "error", "cancelled"];
53
+ declare const OUTCOMES: readonly ["success", "error", "cancelled"];
54
+ export declare const COMMAND_PATHS: readonly ["start", "stop", "status", "doctor", "usage", "usage.redeem", "leaderboard", "accounts.login", "accounts.add", "accounts.rename", "accounts.remove", "accounts.list", "accounts.status", "calls.inspect", "config.path", "config.show", "config.init", "config.edit", "config.import", "config.migrate", "providers.add", "providers.remove", "providers.status", "models.list", "models.info", "remote.add", "remote.install", "remote.list", "remote.show", "remote.use", "remote.remove", "peer.add", "peer.show", "peer.remove", "token.issue", "token.list", "token.revoke", "codex", "claude", "cursor", "opencode", "codex.install", "codex.uninstall", "claude.install", "claude.uninstall", "daemon.reload", "daemon.status", "daemon.auth.show", "daemon.service.install", "daemon.service.uninstall", "daemon.service.status", "daemon.logs", "daemon.start", "daemon.stop", "daemon.restart", "daemon.upgrade"];
55
+ export declare const COMMAND_EXIT_KINDS: readonly ["success", "usage_error", "command_error", "cancelled"];
56
+ export declare const COMMAND_TARGET_KINDS: readonly ["local", "remote", "peer"];
57
+ export declare const COMMAND_OS_VALUES: readonly ["darwin", "linux", "win32", "other"];
58
+ export declare const COMMAND_ARCH_VALUES: readonly ["arm64", "x64", "other"];
59
+ export declare const COMMAND_NODE_MAJOR_VALUES: readonly ["22", "23", "24", "25", "26", "other"];
60
+ export declare const PRODUCT_OPERATIONS: readonly ["config_update", "config_import", "config_reload", "provider_enable", "provider_disable", "account_enroll", "account_enroll_activate", "account_remove", "account_sync", "launcher_prepare", "token_issue", "token_revoke"];
61
+ declare const COUNT_BUCKETS: readonly ["0", "1", "2-5", "6-20", ">20"];
62
+ declare const TOKEN_BUCKETS: readonly ["0", "1-1k", "1k-10k", "10k-100k", ">100k", "unknown"];
63
+ declare const RETRY_BUCKETS: readonly ["0", "1", "2", "3+"];
64
+ declare const BILLING_MODES: readonly ["metered-api", "subscription", "upstream-managed", "unknown"];
65
+ declare const DIALECTS: readonly ["openai-chat", "openai-responses", "anthropic-messages", "openai-embeddings"];
66
+ declare const REQUEST_KINDS: readonly ["chat", "responses", "messages", "embeddings"];
67
+ declare const PREFERENCE_ACTIONS: readonly ["master", "category", "identity-reset"];
68
+ declare const DAEMON_ACTIONS: readonly ["started", "stopped", "restarted", "reloaded"];
69
+ export declare const TELEMETRY_EVENT_DEFINITIONS: {
70
+ readonly "routekit.command_completed": {
71
+ readonly category: "adoption";
72
+ readonly properties: {
73
+ readonly command: {
74
+ readonly type: "string";
75
+ readonly maxLength: 64;
76
+ readonly values: readonly ["start", "stop", "status", "doctor", "usage", "usage.redeem", "leaderboard", "accounts.login", "accounts.add", "accounts.rename", "accounts.remove", "accounts.list", "accounts.status", "calls.inspect", "config.path", "config.show", "config.init", "config.edit", "config.import", "config.migrate", "providers.add", "providers.remove", "providers.status", "models.list", "models.info", "remote.add", "remote.install", "remote.list", "remote.show", "remote.use", "remote.remove", "peer.add", "peer.show", "peer.remove", "token.issue", "token.list", "token.revoke", "codex", "claude", "cursor", "opencode", "codex.install", "codex.uninstall", "claude.install", "claude.uninstall", "daemon.reload", "daemon.status", "daemon.auth.show", "daemon.service.install", "daemon.service.uninstall", "daemon.service.status", "daemon.logs", "daemon.start", "daemon.stop", "daemon.restart", "daemon.upgrade"];
77
+ };
78
+ readonly cli_version: {
79
+ readonly type: "string";
80
+ readonly maxLength: 32;
81
+ };
82
+ readonly os: {
83
+ readonly type: "string";
84
+ readonly maxLength: 16;
85
+ readonly values: readonly ["darwin", "linux", "win32", "other"];
86
+ };
87
+ readonly arch: {
88
+ readonly type: "string";
89
+ readonly maxLength: 16;
90
+ readonly values: readonly ["arm64", "x64", "other"];
91
+ };
92
+ readonly node_major: {
93
+ readonly type: "string";
94
+ readonly maxLength: 8;
95
+ readonly values: readonly ["22", "23", "24", "25", "26", "other"];
96
+ };
97
+ readonly duration_bucket: {
98
+ readonly type: "string";
99
+ readonly maxLength: 16;
100
+ readonly values: readonly ["<1s", "1-10s", "10-60s", "1-5m", "5-30m", ">30m"];
101
+ };
102
+ readonly outcome: {
103
+ readonly type: "string";
104
+ readonly maxLength: 16;
105
+ readonly values: readonly ["success", "error", "cancelled"];
106
+ };
107
+ readonly exit_kind: {
108
+ readonly type: "string";
109
+ readonly maxLength: 24;
110
+ readonly values: readonly ["success", "usage_error", "command_error", "cancelled"];
111
+ };
112
+ readonly is_ci: {
113
+ readonly type: "boolean";
114
+ };
115
+ readonly target_kind: {
116
+ readonly type: "string";
117
+ readonly maxLength: 16;
118
+ readonly values: readonly ["local", "remote", "peer"];
119
+ };
120
+ };
121
+ };
122
+ readonly "routekit.product_operation_completed": {
123
+ readonly category: "adoption";
124
+ readonly properties: {
125
+ readonly operation: {
126
+ readonly type: "string";
127
+ readonly maxLength: 64;
128
+ readonly values: readonly ["config_update", "config_import", "config_reload", "provider_enable", "provider_disable", "account_enroll", "account_enroll_activate", "account_remove", "account_sync", "launcher_prepare", "token_issue", "token_revoke"];
129
+ };
130
+ readonly outcome: {
131
+ readonly type: "string";
132
+ readonly maxLength: 16;
133
+ readonly values: readonly ["success", "error", "cancelled"];
134
+ };
135
+ readonly duration_bucket: {
136
+ readonly type: "string";
137
+ readonly maxLength: 16;
138
+ readonly values: readonly ["<1s", "1-10s", "10-60s", "1-5m", "5-30m", ">30m"];
139
+ };
140
+ readonly version: {
141
+ readonly type: "string";
142
+ readonly maxLength: 32;
143
+ };
144
+ };
145
+ };
146
+ readonly "routekit.daemon_lifecycle": {
147
+ readonly category: "reliability";
148
+ readonly properties: {
149
+ readonly action: {
150
+ readonly type: "string";
151
+ readonly maxLength: 16;
152
+ readonly values: readonly ["started", "stopped", "restarted", "reloaded"];
153
+ };
154
+ readonly outcome: {
155
+ readonly type: "string";
156
+ readonly maxLength: 16;
157
+ readonly values: readonly ["success", "error", "cancelled"];
158
+ };
159
+ readonly supervisor: {
160
+ readonly type: "string";
161
+ readonly maxLength: 16;
162
+ readonly values: readonly ["systemd", "launchd", "detached", "unknown"];
163
+ };
164
+ readonly version: {
165
+ readonly type: "string";
166
+ readonly maxLength: 32;
167
+ };
168
+ };
169
+ };
170
+ readonly "routekit.gateway_usage_summary": {
171
+ readonly category: "usage";
172
+ readonly properties: {
173
+ readonly provider: {
174
+ readonly type: "string";
175
+ readonly maxLength: 100;
176
+ readonly canonical: true;
177
+ };
178
+ readonly model: {
179
+ readonly type: "string";
180
+ readonly maxLength: 200;
181
+ readonly canonical: true;
182
+ };
183
+ readonly dialect: {
184
+ readonly type: "string";
185
+ readonly maxLength: 32;
186
+ readonly values: readonly ["openai-chat", "openai-responses", "anthropic-messages", "openai-embeddings"];
187
+ };
188
+ readonly request_kind: {
189
+ readonly type: "string";
190
+ readonly maxLength: 16;
191
+ readonly values: readonly ["chat", "responses", "messages", "embeddings"];
192
+ };
193
+ readonly stream: {
194
+ readonly type: "boolean";
195
+ };
196
+ readonly billing_mode: {
197
+ readonly type: "string";
198
+ readonly maxLength: 32;
199
+ readonly values: readonly ["metered-api", "subscription", "upstream-managed", "unknown"];
200
+ };
201
+ readonly input_token_bucket: {
202
+ readonly type: "string";
203
+ readonly maxLength: 16;
204
+ readonly values: readonly ["0", "1-1k", "1k-10k", "10k-100k", ">100k", "unknown"];
205
+ };
206
+ readonly output_token_bucket: {
207
+ readonly type: "string";
208
+ readonly maxLength: 16;
209
+ readonly values: readonly ["0", "1-1k", "1k-10k", "10k-100k", ">100k", "unknown"];
210
+ };
211
+ readonly request_count_bucket: {
212
+ readonly type: "string";
213
+ readonly maxLength: 8;
214
+ readonly values: readonly ["0", "1", "2-5", "6-20", ">20"];
215
+ };
216
+ readonly version: {
217
+ readonly type: "string";
218
+ readonly maxLength: 32;
219
+ };
220
+ };
221
+ };
222
+ readonly "routekit.gateway_reliability_summary": {
223
+ readonly category: "reliability";
224
+ readonly properties: {
225
+ readonly provider: {
226
+ readonly type: "string";
227
+ readonly maxLength: 100;
228
+ readonly canonical: true;
229
+ };
230
+ readonly model: {
231
+ readonly type: "string";
232
+ readonly maxLength: 200;
233
+ readonly canonical: true;
234
+ };
235
+ readonly dialect: {
236
+ readonly type: "string";
237
+ readonly maxLength: 32;
238
+ readonly values: readonly ["openai-chat", "openai-responses", "anthropic-messages", "openai-embeddings"];
239
+ };
240
+ readonly request_kind: {
241
+ readonly type: "string";
242
+ readonly maxLength: 16;
243
+ readonly values: readonly ["chat", "responses", "messages", "embeddings"];
244
+ };
245
+ readonly stream: {
246
+ readonly type: "boolean";
247
+ };
248
+ readonly outcome: {
249
+ readonly type: "string";
250
+ readonly maxLength: 16;
251
+ readonly values: readonly ["success", "error", "cancelled"];
252
+ };
253
+ readonly latency_bucket: {
254
+ readonly type: "string";
255
+ readonly maxLength: 16;
256
+ readonly values: readonly ["<1s", "1-10s", "10-60s", "1-5m", "5-30m", ">30m"];
257
+ };
258
+ readonly retry_bucket: {
259
+ readonly type: "string";
260
+ readonly maxLength: 8;
261
+ readonly values: readonly ["0", "1", "2", "3+"];
262
+ };
263
+ readonly failover: {
264
+ readonly type: "boolean";
265
+ };
266
+ readonly request_count_bucket: {
267
+ readonly type: "string";
268
+ readonly maxLength: 8;
269
+ readonly values: readonly ["0", "1", "2-5", "6-20", ">20"];
270
+ };
271
+ readonly version: {
272
+ readonly type: "string";
273
+ readonly maxLength: 32;
274
+ };
275
+ };
276
+ };
277
+ readonly "routekit.telemetry_preference_changed": {
278
+ readonly category: "adoption";
279
+ readonly properties: {
280
+ readonly action: {
281
+ readonly type: "string";
282
+ readonly maxLength: 32;
283
+ readonly values: readonly ["master", "category", "identity-reset"];
284
+ };
285
+ readonly category: {
286
+ readonly type: "string";
287
+ readonly maxLength: 16;
288
+ readonly values: readonly ["usage", "reliability", "adoption"];
289
+ readonly required: false;
290
+ };
291
+ readonly enabled: {
292
+ readonly type: "boolean";
293
+ };
294
+ readonly source: {
295
+ readonly type: "string";
296
+ readonly maxLength: 16;
297
+ readonly values: readonly ["do-not-track", "env", "config", "default"];
298
+ };
299
+ readonly version: {
300
+ readonly type: "string";
301
+ readonly maxLength: 32;
302
+ };
303
+ };
304
+ };
305
+ };
306
+ export type TelemetryEventName = keyof typeof TELEMETRY_EVENT_DEFINITIONS;
307
+ export type CommandCompletedProperties = {
308
+ command: (typeof COMMAND_PATHS)[number];
309
+ cli_version: string;
310
+ os: (typeof COMMAND_OS_VALUES)[number];
311
+ arch: (typeof COMMAND_ARCH_VALUES)[number];
312
+ node_major: (typeof COMMAND_NODE_MAJOR_VALUES)[number];
313
+ duration_bucket: (typeof DURATION_BUCKETS)[number];
314
+ outcome: (typeof OUTCOMES)[number];
315
+ exit_kind: (typeof COMMAND_EXIT_KINDS)[number];
316
+ is_ci: boolean;
317
+ target_kind: (typeof COMMAND_TARGET_KINDS)[number];
318
+ };
319
+ export type TelemetryEventProperties = {
320
+ "routekit.command_completed": CommandCompletedProperties;
321
+ "routekit.product_operation_completed": {
322
+ operation: (typeof PRODUCT_OPERATIONS)[number];
323
+ outcome: (typeof OUTCOMES)[number];
324
+ duration_bucket: (typeof DURATION_BUCKETS)[number];
325
+ version: string;
326
+ };
327
+ "routekit.daemon_lifecycle": {
328
+ action: (typeof DAEMON_ACTIONS)[number];
329
+ outcome: (typeof OUTCOMES)[number];
330
+ supervisor: "systemd" | "launchd" | "detached" | "unknown";
331
+ version: string;
332
+ };
333
+ /** Gateway summaries are the only families permitted to carry canonical provider/model identifiers. */
334
+ "routekit.gateway_usage_summary": {
335
+ provider: string;
336
+ model: string;
337
+ dialect: (typeof DIALECTS)[number];
338
+ request_kind: (typeof REQUEST_KINDS)[number];
339
+ stream: boolean;
340
+ billing_mode: (typeof BILLING_MODES)[number];
341
+ input_token_bucket: (typeof TOKEN_BUCKETS)[number];
342
+ output_token_bucket: (typeof TOKEN_BUCKETS)[number];
343
+ request_count_bucket: (typeof COUNT_BUCKETS)[number];
344
+ version: string;
345
+ };
346
+ "routekit.gateway_reliability_summary": {
347
+ provider: string;
348
+ model: string;
349
+ dialect: (typeof DIALECTS)[number];
350
+ request_kind: (typeof REQUEST_KINDS)[number];
351
+ stream: boolean;
352
+ outcome: (typeof OUTCOMES)[number];
353
+ latency_bucket: (typeof DURATION_BUCKETS)[number];
354
+ retry_bucket: (typeof RETRY_BUCKETS)[number];
355
+ failover: boolean;
356
+ request_count_bucket: (typeof COUNT_BUCKETS)[number];
357
+ version: string;
358
+ };
359
+ "routekit.telemetry_preference_changed": {
360
+ action: (typeof PREFERENCE_ACTIONS)[number];
361
+ category?: TelemetryCategory;
362
+ enabled: boolean;
363
+ source: ConsentDecision["source"];
364
+ version: string;
365
+ };
366
+ };
367
+ export type TelemetrySchemaInventory = Readonly<Record<TelemetryEventName, {
368
+ category: TelemetryCategory;
369
+ fields: readonly string[];
370
+ }>>;
371
+ export declare const TELEMETRY_SCHEMA_VERSION = 1;
372
+ export declare const TELEMETRY_SCHEMA_INVENTORY: TelemetrySchemaInventory;
373
+ export type BuiltTelemetryEvent = {
374
+ event: TelemetryEventName;
375
+ category: TelemetryCategory;
376
+ properties: Record<string, unknown> & {
377
+ schema_version: 1;
378
+ $process_person_profile: false;
379
+ $ip: null;
380
+ };
381
+ };
382
+ export declare function buildTelemetryEvent<N extends TelemetryEventName>(name: N, source: TelemetryEventProperties[N]): BuiltTelemetryEvent;
383
+ export declare function telemetryStatusMetadata(decision: ConsentDecision, destinationOrFields: TelemetryDestination | TelemetryFieldMap, schema?: TelemetrySchemaInventory): TelemetryStatus | {
384
+ enabled: boolean;
385
+ source: ConsentDecision["source"];
386
+ installId: string | null;
387
+ fields: TelemetryFieldMap;
388
+ };
389
+ /** @deprecated Prefer buildTelemetryEvent, which rejects unknown properties. */
40
390
  export declare function allowlistedProperties(source: Record<string, unknown>, allow: readonly string[]): Record<string, unknown>;
41
391
  export declare function anonymousEventProperties(properties: Record<string, unknown>): Record<string, unknown>;
42
392
  export declare function boundedShutdown(shutdown: () => Promise<unknown>, timeoutMs?: number): Promise<void>;
393
+ export {};
package/dist/index.js CHANGED
@@ -2,7 +2,13 @@ import { randomUUID } from "node:crypto";
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { writeFileAtomic } from "@velum-labs/routekit-runtime";
5
- /** Fields shared by every CLI's anonymous command event. */
5
+ export const TELEMETRY_CATEGORIES = ["usage", "reliability", "adoption"];
6
+ export const DEFAULT_TELEMETRY_CATEGORIES = Object.freeze({
7
+ usage: true,
8
+ reliability: true,
9
+ adoption: true
10
+ });
11
+ /** @deprecated Use the event schema inventory instead. */
6
12
  export const CLI_COMMAND_TELEMETRY_FIELDS = [
7
13
  "command",
8
14
  "cli_version",
@@ -13,68 +19,94 @@ export const CLI_COMMAND_TELEMETRY_FIELDS = [
13
19
  "exit_kind",
14
20
  "is_ci"
15
21
  ];
16
- /**
17
- * Shared machine-readable consent status. Products may add operational fields
18
- * and render this metadata differently, but consent semantics stay identical.
19
- */
20
- export function telemetryStatusMetadata(decision, fields) {
22
+ const truthy = (value) => value !== undefined && ["1", "true", "on", "yes"].includes(value.toLowerCase());
23
+ const falsy = (value) => value !== undefined && ["0", "false", "off", "no"].includes(value.toLowerCase());
24
+ function categories(value) {
21
25
  return {
22
- enabled: decision.enabled,
23
- source: decision.source,
24
- installId: decision.installId ?? null,
25
- fields
26
+ usage: value?.usage ?? DEFAULT_TELEMETRY_CATEGORIES.usage,
27
+ reliability: value?.reliability ?? DEFAULT_TELEMETRY_CATEGORIES.reliability,
28
+ adoption: value?.adoption ?? DEFAULT_TELEMETRY_CATEGORIES.adoption
26
29
  };
27
30
  }
28
- const truthy = (value) => value !== undefined && ["1", "true", "on", "yes"].includes(value.toLowerCase());
29
- const falsy = (value) => value !== undefined && ["0", "false", "off", "no"].includes(value.toLowerCase());
30
31
  export function createConsentManager(options) {
32
+ let ephemeralInstallId;
33
+ const nextId = () => (options.randomId ?? randomUUID)();
34
+ const decidedAt = () => (options.now ?? (() => new Date()))().toISOString();
31
35
  const read = () => {
32
36
  const path = options.path();
33
37
  if (!existsSync(path))
34
38
  return undefined;
35
39
  try {
36
40
  const parsed = JSON.parse(readFileSync(path, "utf8"));
37
- if (typeof parsed === "object" &&
38
- parsed !== null &&
39
- typeof parsed.enabled === "boolean") {
40
- return parsed;
41
+ if (typeof parsed !== "object" || parsed === null)
42
+ return undefined;
43
+ const candidate = parsed;
44
+ if (typeof candidate.enabled !== "boolean")
45
+ return undefined;
46
+ const normalized = {
47
+ enabled: candidate.enabled,
48
+ categories: categories(candidate.categories),
49
+ ...(candidate.decidedAt !== undefined ? { decidedAt: candidate.decidedAt } : {})
50
+ };
51
+ if (candidate.enabled &&
52
+ typeof candidate.installId === "string" &&
53
+ candidate.installId.length > 0) {
54
+ normalized.installId = candidate.installId;
41
55
  }
56
+ return normalized;
42
57
  }
43
58
  catch {
44
- // Corrupt consent is undecided and therefore disabled.
59
+ return undefined;
45
60
  }
46
- return undefined;
47
61
  };
48
62
  const write = (value) => {
49
63
  const path = options.path();
64
+ const normalized = {
65
+ enabled: value.enabled,
66
+ categories: categories(value.categories),
67
+ ...(value.enabled && value.installId !== undefined ? { installId: value.installId } : {}),
68
+ ...(value.decidedAt !== undefined ? { decidedAt: value.decidedAt } : {})
69
+ };
50
70
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
51
71
  chmodSync(dirname(path), 0o700);
52
- writeFileAtomic(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
72
+ writeFileAtomic(path, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 });
53
73
  chmodSync(path, 0o600);
54
74
  };
75
+ const stableId = (file) => {
76
+ if (file?.enabled && file.installId !== undefined)
77
+ return file.installId;
78
+ ephemeralInstallId ??= nextId();
79
+ return ephemeralInstallId;
80
+ };
55
81
  const resolve = (env = process.env) => {
82
+ const file = read();
83
+ const selectedCategories = categories(file?.categories);
56
84
  if (truthy(env[options.doNotTrackVariable ?? "DO_NOT_TRACK"])) {
57
- return { enabled: false, source: "do-not-track" };
85
+ return { enabled: false, source: "do-not-track", categories: selectedCategories };
58
86
  }
59
87
  const override = env[options.environmentVariable];
60
- if (falsy(override))
61
- return { enabled: false, source: "env" };
62
- const file = read();
88
+ if (falsy(override)) {
89
+ return { enabled: false, source: "env", categories: selectedCategories };
90
+ }
63
91
  if (truthy(override)) {
64
92
  return {
65
93
  enabled: true,
66
94
  source: "env",
67
- installId: file?.installId ?? (options.randomId ?? randomUUID)()
95
+ categories: selectedCategories,
96
+ installId: stableId(file)
68
97
  };
69
98
  }
70
99
  if (file !== undefined) {
100
+ if (!file.enabled)
101
+ return { enabled: false, source: "config", categories: selectedCategories };
71
102
  return {
72
- enabled: file.enabled,
103
+ enabled: true,
73
104
  source: "config",
74
- ...(file.installId !== undefined ? { installId: file.installId } : {})
105
+ categories: selectedCategories,
106
+ installId: stableId(file)
75
107
  };
76
108
  }
77
- return { enabled: false, source: "default" };
109
+ return { enabled: false, source: "default", categories: selectedCategories };
78
110
  };
79
111
  return {
80
112
  path: options.path,
@@ -84,23 +116,75 @@ export function createConsentManager(options) {
84
116
  const existing = read();
85
117
  const file = {
86
118
  enabled: true,
87
- installId: existing?.installId ?? (options.randomId ?? randomUUID)(),
88
- decidedAt: (options.now ?? (() => new Date()))().toISOString()
119
+ categories: categories(existing?.categories),
120
+ installId: existing?.enabled ? (existing.installId ?? nextId()) : nextId(),
121
+ decidedAt: decidedAt()
89
122
  };
123
+ ephemeralInstallId = file.installId;
90
124
  write(file);
91
125
  return file;
92
126
  },
93
127
  disable() {
94
- write({
128
+ ephemeralInstallId = undefined;
129
+ const file = {
95
130
  enabled: false,
96
- decidedAt: (options.now ?? (() => new Date()))().toISOString()
97
- });
131
+ categories: categories(read()?.categories),
132
+ decidedAt: decidedAt()
133
+ };
134
+ write(file);
135
+ return file;
136
+ },
137
+ setCategory(category, enabled) {
138
+ const existing = read();
139
+ const file = {
140
+ enabled: existing?.enabled ?? false,
141
+ categories: { ...categories(existing?.categories), [category]: enabled },
142
+ ...(existing?.enabled && existing.installId !== undefined
143
+ ? { installId: existing.installId }
144
+ : {}),
145
+ decidedAt: decidedAt()
146
+ };
147
+ write(file);
148
+ return file;
149
+ },
150
+ resetIdentity(env = process.env) {
151
+ const decision = resolve(env);
152
+ if (!decision.enabled) {
153
+ ephemeralInstallId = undefined;
154
+ const existing = read();
155
+ if (existing?.enabled) {
156
+ const disabled = {
157
+ enabled: false,
158
+ categories: categories(existing.categories),
159
+ decidedAt: decidedAt()
160
+ };
161
+ write(disabled);
162
+ return disabled;
163
+ }
164
+ return existing;
165
+ }
166
+ const installId = nextId();
167
+ ephemeralInstallId = installId;
168
+ const existing = read();
169
+ if (existing?.enabled) {
170
+ const file = {
171
+ enabled: true,
172
+ categories: categories(existing.categories),
173
+ installId,
174
+ decidedAt: decidedAt()
175
+ };
176
+ write(file);
177
+ return file;
178
+ }
179
+ return undefined;
98
180
  },
99
181
  clear() {
182
+ ephemeralInstallId = undefined;
100
183
  rmSync(options.path(), { force: true });
101
184
  }
102
185
  };
103
186
  }
187
+ export const DURATION_BUCKETS = ["<1s", "1-10s", "10-60s", "1-5m", "5-30m", ">30m"];
104
188
  export function durationBucket(ms) {
105
189
  if (ms < 1_000)
106
190
  return "<1s";
@@ -114,12 +198,269 @@ export function durationBucket(ms) {
114
198
  return "5-30m";
115
199
  return ">30m";
116
200
  }
201
+ export const TELEMETRY_OUTCOMES = ["success", "error", "cancelled"];
202
+ const OUTCOMES = TELEMETRY_OUTCOMES;
203
+ export const COMMAND_PATHS = [
204
+ "start",
205
+ "stop",
206
+ "status",
207
+ "doctor",
208
+ "usage",
209
+ "usage.redeem",
210
+ "leaderboard",
211
+ "accounts.login",
212
+ "accounts.add",
213
+ "accounts.rename",
214
+ "accounts.remove",
215
+ "accounts.list",
216
+ "accounts.status",
217
+ "calls.inspect",
218
+ "config.path",
219
+ "config.show",
220
+ "config.init",
221
+ "config.edit",
222
+ "config.import",
223
+ "config.migrate",
224
+ "providers.add",
225
+ "providers.remove",
226
+ "providers.status",
227
+ "models.list",
228
+ "models.info",
229
+ "remote.add",
230
+ "remote.install",
231
+ "remote.list",
232
+ "remote.show",
233
+ "remote.use",
234
+ "remote.remove",
235
+ "peer.add",
236
+ "peer.show",
237
+ "peer.remove",
238
+ "token.issue",
239
+ "token.list",
240
+ "token.revoke",
241
+ "codex",
242
+ "claude",
243
+ "cursor",
244
+ "opencode",
245
+ "codex.install",
246
+ "codex.uninstall",
247
+ "claude.install",
248
+ "claude.uninstall",
249
+ "daemon.reload",
250
+ "daemon.status",
251
+ "daemon.auth.show",
252
+ "daemon.service.install",
253
+ "daemon.service.uninstall",
254
+ "daemon.service.status",
255
+ "daemon.logs",
256
+ "daemon.start",
257
+ "daemon.stop",
258
+ "daemon.restart",
259
+ "daemon.upgrade"
260
+ ];
261
+ export const COMMAND_EXIT_KINDS = ["success", "usage_error", "command_error", "cancelled"];
262
+ export const COMMAND_TARGET_KINDS = ["local", "remote", "peer"];
263
+ export const COMMAND_OS_VALUES = ["darwin", "linux", "win32", "other"];
264
+ export const COMMAND_ARCH_VALUES = ["arm64", "x64", "other"];
265
+ export const COMMAND_NODE_MAJOR_VALUES = ["22", "23", "24", "25", "26", "other"];
266
+ export const PRODUCT_OPERATIONS = [
267
+ "config_update",
268
+ "config_import",
269
+ "config_reload",
270
+ "provider_enable",
271
+ "provider_disable",
272
+ "account_enroll",
273
+ "account_enroll_activate",
274
+ "account_remove",
275
+ "account_sync",
276
+ "launcher_prepare",
277
+ "token_issue",
278
+ "token_revoke"
279
+ ];
280
+ const COUNT_BUCKETS = ["0", "1", "2-5", "6-20", ">20"];
281
+ const TOKEN_BUCKETS = ["0", "1-1k", "1k-10k", "10k-100k", ">100k", "unknown"];
282
+ const RETRY_BUCKETS = ["0", "1", "2", "3+"];
283
+ const BILLING_MODES = ["metered-api", "subscription", "upstream-managed", "unknown"];
284
+ const DIALECTS = [
285
+ "openai-chat",
286
+ "openai-responses",
287
+ "anthropic-messages",
288
+ "openai-embeddings"
289
+ ];
290
+ const REQUEST_KINDS = ["chat", "responses", "messages", "embeddings"];
291
+ const PREFERENCE_ACTIONS = ["master", "category", "identity-reset"];
292
+ const DAEMON_ACTIONS = ["started", "stopped", "restarted", "reloaded"];
293
+ export const TELEMETRY_EVENT_DEFINITIONS = {
294
+ "routekit.command_completed": {
295
+ category: "adoption",
296
+ properties: {
297
+ command: { type: "string", maxLength: 64, values: COMMAND_PATHS },
298
+ cli_version: { type: "string", maxLength: 32 },
299
+ os: { type: "string", maxLength: 16, values: COMMAND_OS_VALUES },
300
+ arch: { type: "string", maxLength: 16, values: COMMAND_ARCH_VALUES },
301
+ node_major: { type: "string", maxLength: 8, values: COMMAND_NODE_MAJOR_VALUES },
302
+ duration_bucket: { type: "string", maxLength: 16, values: DURATION_BUCKETS },
303
+ outcome: { type: "string", maxLength: 16, values: OUTCOMES },
304
+ exit_kind: { type: "string", maxLength: 24, values: COMMAND_EXIT_KINDS },
305
+ is_ci: { type: "boolean" },
306
+ target_kind: { type: "string", maxLength: 16, values: COMMAND_TARGET_KINDS }
307
+ }
308
+ },
309
+ "routekit.product_operation_completed": {
310
+ category: "adoption",
311
+ properties: {
312
+ operation: { type: "string", maxLength: 64, values: PRODUCT_OPERATIONS },
313
+ outcome: { type: "string", maxLength: 16, values: OUTCOMES },
314
+ duration_bucket: { type: "string", maxLength: 16, values: DURATION_BUCKETS },
315
+ version: { type: "string", maxLength: 32 }
316
+ }
317
+ },
318
+ "routekit.daemon_lifecycle": {
319
+ category: "reliability",
320
+ properties: {
321
+ action: { type: "string", maxLength: 16, values: DAEMON_ACTIONS },
322
+ outcome: { type: "string", maxLength: 16, values: OUTCOMES },
323
+ supervisor: {
324
+ type: "string",
325
+ maxLength: 16,
326
+ values: ["systemd", "launchd", "detached", "unknown"]
327
+ },
328
+ version: { type: "string", maxLength: 32 }
329
+ }
330
+ },
331
+ "routekit.gateway_usage_summary": {
332
+ category: "usage",
333
+ properties: {
334
+ provider: { type: "string", maxLength: 100, canonical: true },
335
+ model: { type: "string", maxLength: 200, canonical: true },
336
+ dialect: { type: "string", maxLength: 32, values: DIALECTS },
337
+ request_kind: { type: "string", maxLength: 16, values: REQUEST_KINDS },
338
+ stream: { type: "boolean" },
339
+ billing_mode: { type: "string", maxLength: 32, values: BILLING_MODES },
340
+ input_token_bucket: { type: "string", maxLength: 16, values: TOKEN_BUCKETS },
341
+ output_token_bucket: { type: "string", maxLength: 16, values: TOKEN_BUCKETS },
342
+ request_count_bucket: { type: "string", maxLength: 8, values: COUNT_BUCKETS },
343
+ version: { type: "string", maxLength: 32 }
344
+ }
345
+ },
346
+ "routekit.gateway_reliability_summary": {
347
+ category: "reliability",
348
+ properties: {
349
+ provider: { type: "string", maxLength: 100, canonical: true },
350
+ model: { type: "string", maxLength: 200, canonical: true },
351
+ dialect: { type: "string", maxLength: 32, values: DIALECTS },
352
+ request_kind: { type: "string", maxLength: 16, values: REQUEST_KINDS },
353
+ stream: { type: "boolean" },
354
+ outcome: { type: "string", maxLength: 16, values: OUTCOMES },
355
+ latency_bucket: { type: "string", maxLength: 16, values: DURATION_BUCKETS },
356
+ retry_bucket: { type: "string", maxLength: 8, values: RETRY_BUCKETS },
357
+ failover: { type: "boolean" },
358
+ request_count_bucket: { type: "string", maxLength: 8, values: COUNT_BUCKETS },
359
+ version: { type: "string", maxLength: 32 }
360
+ }
361
+ },
362
+ "routekit.telemetry_preference_changed": {
363
+ category: "adoption",
364
+ properties: {
365
+ action: { type: "string", maxLength: 32, values: PREFERENCE_ACTIONS },
366
+ category: { type: "string", maxLength: 16, values: TELEMETRY_CATEGORIES, required: false },
367
+ enabled: { type: "boolean" },
368
+ source: {
369
+ type: "string",
370
+ maxLength: 16,
371
+ values: ["do-not-track", "env", "config", "default"]
372
+ },
373
+ version: { type: "string", maxLength: 32 }
374
+ }
375
+ }
376
+ };
377
+ export const TELEMETRY_SCHEMA_VERSION = 1;
378
+ export const TELEMETRY_SCHEMA_INVENTORY = Object.freeze(Object.fromEntries(Object.entries(TELEMETRY_EVENT_DEFINITIONS).map(([name, definition]) => [
379
+ name,
380
+ { category: definition.category, fields: Object.keys(definition.properties) }
381
+ ])));
382
+ const FORBIDDEN_KEYS = /(?:^|_)(?:id|label|path|body|prompt|response|error|cost|usage|timing|token|secret|key)(?:$|_)/i;
383
+ const FORBIDDEN_VALUE = /(?:bearer\s+|sk-[a-z0-9]|-----begin [a-z ]+private key-----|(?:^|\s)\/(?:users|home|var|tmp)\/)/i;
384
+ const CANONICAL_ID = /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/;
385
+ export function buildTelemetryEvent(name, source) {
386
+ if (!Object.hasOwn(TELEMETRY_EVENT_DEFINITIONS, name)) {
387
+ throw new TypeError(`unknown telemetry event: ${name}`);
388
+ }
389
+ const eventName = name;
390
+ const definition = TELEMETRY_EVENT_DEFINITIONS[eventName];
391
+ const input = source;
392
+ for (const key of Object.keys(input)) {
393
+ if (definition.properties[key] === undefined) {
394
+ const sensitive = FORBIDDEN_KEYS.test(key) ? " sensitive" : "";
395
+ throw new TypeError(`unknown${sensitive} telemetry property ${key} for ${name}`);
396
+ }
397
+ }
398
+ for (const [key, validator] of Object.entries(definition.properties)) {
399
+ if (validator.required !== false && input[key] === undefined) {
400
+ throw new TypeError(`missing telemetry property ${key} for ${name}`);
401
+ }
402
+ }
403
+ const output = {};
404
+ for (const [key, value] of Object.entries(input)) {
405
+ const validator = definition.properties[key];
406
+ if (validator === undefined) {
407
+ const sensitive = FORBIDDEN_KEYS.test(key) ? " sensitive" : "";
408
+ throw new TypeError(`unknown${sensitive} telemetry property ${key} for ${name}`);
409
+ }
410
+ if (validator.type === "boolean") {
411
+ if (typeof value !== "boolean")
412
+ throw new TypeError(`invalid telemetry property ${key}`);
413
+ }
414
+ else {
415
+ if (typeof value !== "string" || value.length === 0 || value.length > validator.maxLength) {
416
+ throw new TypeError(`invalid telemetry property ${key}`);
417
+ }
418
+ if (validator.values !== undefined && !validator.values.includes(value)) {
419
+ throw new TypeError(`invalid telemetry property ${key}`);
420
+ }
421
+ if (validator.canonical && !CANONICAL_ID.test(value)) {
422
+ throw new TypeError(`invalid telemetry property ${key}`);
423
+ }
424
+ if (FORBIDDEN_VALUE.test(value))
425
+ throw new TypeError(`forbidden telemetry value for ${key}`);
426
+ }
427
+ output[key] = value;
428
+ }
429
+ return {
430
+ event: eventName,
431
+ category: definition.category,
432
+ properties: anonymousEventProperties({
433
+ ...output,
434
+ schema_version: TELEMETRY_SCHEMA_VERSION
435
+ })
436
+ };
437
+ }
438
+ export function telemetryStatusMetadata(decision, destinationOrFields, schema = TELEMETRY_SCHEMA_INVENTORY) {
439
+ if (!("provider" in destinationOrFields) ||
440
+ !("host" in destinationOrFields) ||
441
+ !("configured" in destinationOrFields)) {
442
+ return {
443
+ enabled: decision.enabled,
444
+ source: decision.source,
445
+ installId: decision.installId ?? null,
446
+ fields: destinationOrFields
447
+ };
448
+ }
449
+ return {
450
+ enabled: decision.enabled,
451
+ source: decision.source,
452
+ categories: decision.categories,
453
+ installIdPresent: decision.installId !== undefined,
454
+ destination: destinationOrFields,
455
+ schema
456
+ };
457
+ }
458
+ /** @deprecated Prefer buildTelemetryEvent, which rejects unknown properties. */
117
459
  export function allowlistedProperties(source, allow) {
118
460
  const properties = {};
119
- for (const key of allow) {
461
+ for (const key of allow)
120
462
  if (source[key] !== undefined)
121
463
  properties[key] = source[key];
122
- }
123
464
  return properties;
124
465
  }
125
466
  export function anonymousEventProperties(properties) {
@@ -1,50 +1,196 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdtempSync, rmSync } from "node:fs";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import test from "node:test";
6
- import { anonymousEventProperties, CLI_COMMAND_TELEMETRY_FIELDS, createConsentManager, telemetryStatusMetadata } from "../index.js";
7
- test("consent is parameterized and anonymous events redact network identity", () => {
6
+ import { buildTelemetryEvent, createConsentManager, DEFAULT_TELEMETRY_CATEGORIES, TELEMETRY_SCHEMA_INVENTORY, telemetryStatusMetadata } from "../index.js";
7
+ function fixture(ids = ["id-1", "id-2", "id-3"]) {
8
8
  const root = mkdtempSync(join(tmpdir(), "routekit-telemetry-"));
9
+ let index = 0;
10
+ const manager = createConsentManager({
11
+ path: () => join(root, "consent.json"),
12
+ environmentVariable: "ROUTEKIT_TELEMETRY",
13
+ randomId: () => ids[index++] ?? `id-${index}`,
14
+ now: () => new Date("2026-01-01T00:00:00.000Z")
15
+ });
16
+ return { root, path: join(root, "consent.json"), manager };
17
+ }
18
+ test("consent defaults off and migrates old files logically", () => {
19
+ const { root, path, manager } = fixture();
9
20
  try {
10
- const manager = createConsentManager({
11
- path: () => join(root, "consent.json"),
12
- environmentVariable: "EXAMPLE_TELEMETRY",
13
- randomId: () => "install-id",
14
- now: () => new Date("2026-01-01T00:00:00.000Z")
21
+ assert.deepEqual(manager.resolve({}), {
22
+ enabled: false,
23
+ source: "default",
24
+ categories: DEFAULT_TELEMETRY_CATEGORIES
15
25
  });
16
- assert.equal(manager.resolve({}).enabled, false);
17
- assert.equal(manager.enable().installId, "install-id");
18
- assert.equal(manager.resolve({}).enabled, true);
19
- assert.deepEqual(anonymousEventProperties({ command: "test" }), {
20
- command: "test",
21
- $process_person_profile: false,
22
- $ip: null
26
+ writeFileSync(path, JSON.stringify({ enabled: true, installId: "legacy" }));
27
+ assert.deepEqual(manager.resolve({}), {
28
+ enabled: true,
29
+ source: "config",
30
+ categories: DEFAULT_TELEMETRY_CATEGORIES,
31
+ installId: "legacy"
23
32
  });
33
+ writeFileSync(path, JSON.stringify({ enabled: false, installId: "must-disappear" }));
34
+ assert.equal(manager.read()?.installId, undefined);
35
+ }
36
+ finally {
37
+ rmSync(root, { recursive: true, force: true });
38
+ }
39
+ });
40
+ test("consent truthy/falsy matrix, DNT precedence, corruption, and identity stability", () => {
41
+ const { root, path, manager } = fixture();
42
+ try {
43
+ manager.enable();
44
+ for (const value of ["1", "true", "TRUE", "on", "yes"]) {
45
+ assert.equal(manager.resolve({ ROUTEKIT_TELEMETRY: value }).enabled, true, value);
46
+ }
47
+ for (const value of ["0", "false", "FALSE", "off", "no"]) {
48
+ const decision = manager.resolve({ ROUTEKIT_TELEMETRY: value });
49
+ assert.equal(decision.enabled, false, value);
50
+ assert.equal(decision.source, "env");
51
+ }
52
+ for (const value of ["1", "true", "on", "yes"]) {
53
+ const decision = manager.resolve({ ROUTEKIT_TELEMETRY: "1", DO_NOT_TRACK: value });
54
+ assert.equal(decision.enabled, false, value);
55
+ assert.equal(decision.source, "do-not-track");
56
+ }
57
+ manager.disable();
58
+ const first = manager.resolve({ ROUTEKIT_TELEMETRY: "yes" });
59
+ const second = manager.resolve({ ROUTEKIT_TELEMETRY: "yes" });
60
+ assert.equal(first.installId, second.installId);
61
+ writeFileSync(path, "{broken json");
62
+ assert.equal(manager.resolve({}).source, "default");
63
+ assert.equal(manager.resolve({}).enabled, false);
24
64
  }
25
65
  finally {
26
66
  rmSync(root, { recursive: true, force: true });
27
67
  }
28
68
  });
29
- test("shared CLI telemetry metadata supports product-specific fields and presentation", () => {
30
- const fields = {
31
- "cli.command": [...CLI_COMMAND_TELEMETRY_FIELDS, "product_mode"]
32
- };
33
- assert.deepEqual(fields["cli.command"], [
34
- "command",
35
- "cli_version",
36
- "os",
37
- "arch",
38
- "node_major",
39
- "duration_bucket",
40
- "exit_kind",
41
- "is_ci",
42
- "product_mode"
43
- ]);
44
- assert.deepEqual(telemetryStatusMetadata({ enabled: false, source: "do-not-track" }, fields), {
45
- enabled: false,
46
- source: "do-not-track",
47
- installId: null,
48
- fields
69
+ test("category changes, disable, re-enable, and identity reset preserve invariants", () => {
70
+ const { root, path, manager } = fixture();
71
+ try {
72
+ assert.equal(manager.enable().installId, "id-1");
73
+ manager.setCategory("usage", false);
74
+ assert.equal(manager.resolve({}).categories.usage, false);
75
+ assert.equal(manager.resetIdentity({})?.installId, "id-2");
76
+ manager.disable();
77
+ assert.equal(manager.resolve({}).installId, undefined);
78
+ assert.equal(JSON.parse(readFileSync(path, "utf8")).installId, undefined);
79
+ assert.deepEqual(manager.resetIdentity({}), manager.read());
80
+ assert.equal(manager.enable().installId, "id-3");
81
+ }
82
+ finally {
83
+ rmSync(root, { recursive: true, force: true });
84
+ }
85
+ });
86
+ test("event builder validates exact schemas and privacy canaries", () => {
87
+ assert.deepEqual(buildTelemetryEvent("routekit.gateway_usage_summary", {
88
+ provider: "openai",
89
+ model: "openai/gpt-5.2",
90
+ dialect: "openai-responses",
91
+ request_kind: "responses",
92
+ stream: true,
93
+ billing_mode: "metered-api",
94
+ input_token_bucket: "1k-10k",
95
+ output_token_bucket: "1-1k",
96
+ request_count_bucket: "1",
97
+ version: "0.17.0"
98
+ }).properties.$ip, null);
99
+ assert.equal(buildTelemetryEvent("routekit.gateway_reliability_summary", {
100
+ provider: "openai",
101
+ model: "openai/gpt-5.2",
102
+ dialect: "openai-responses",
103
+ request_kind: "responses",
104
+ stream: true,
105
+ outcome: "success",
106
+ latency_bucket: "1-10s",
107
+ retry_bucket: "1",
108
+ failover: false,
109
+ request_count_bucket: "1",
110
+ version: "0.17.0"
111
+ }).category, "reliability");
112
+ assert.throws(() => buildTelemetryEvent("routekit.unknown", {}), /unknown telemetry event/);
113
+ assert.throws(() => buildTelemetryEvent("routekit.command_completed", { path: "/tmp/a" }), /sensitive/);
114
+ assert.throws(() => buildTelemetryEvent("routekit.command_completed", { command: "Bearer secret" }), /forbidden|missing/);
115
+ assert.throws(() => buildTelemetryEvent("routekit.gateway_usage_summary", { provider: "open ai" }), /invalid|missing/);
116
+ assert.throws(() => buildTelemetryEvent("routekit.gateway_usage_summary", { latency_ms: 12 }), /unknown/);
117
+ });
118
+ test("schema inventory exposes exact category-truthful event families and fields", () => {
119
+ assert.deepEqual(TELEMETRY_SCHEMA_INVENTORY, {
120
+ "routekit.command_completed": {
121
+ category: "adoption",
122
+ fields: [
123
+ "command",
124
+ "cli_version",
125
+ "os",
126
+ "arch",
127
+ "node_major",
128
+ "duration_bucket",
129
+ "outcome",
130
+ "exit_kind",
131
+ "is_ci",
132
+ "target_kind"
133
+ ]
134
+ },
135
+ "routekit.product_operation_completed": {
136
+ category: "adoption",
137
+ fields: ["operation", "outcome", "duration_bucket", "version"]
138
+ },
139
+ "routekit.daemon_lifecycle": {
140
+ category: "reliability",
141
+ fields: ["action", "outcome", "supervisor", "version"]
142
+ },
143
+ "routekit.gateway_usage_summary": {
144
+ category: "usage",
145
+ fields: [
146
+ "provider",
147
+ "model",
148
+ "dialect",
149
+ "request_kind",
150
+ "stream",
151
+ "billing_mode",
152
+ "input_token_bucket",
153
+ "output_token_bucket",
154
+ "request_count_bucket",
155
+ "version"
156
+ ]
157
+ },
158
+ "routekit.gateway_reliability_summary": {
159
+ category: "reliability",
160
+ fields: [
161
+ "provider",
162
+ "model",
163
+ "dialect",
164
+ "request_kind",
165
+ "stream",
166
+ "outcome",
167
+ "latency_bucket",
168
+ "retry_bucket",
169
+ "failover",
170
+ "request_count_bucket",
171
+ "version"
172
+ ]
173
+ },
174
+ "routekit.telemetry_preference_changed": {
175
+ category: "adoption",
176
+ fields: ["action", "category", "enabled", "source", "version"]
177
+ }
178
+ });
179
+ });
180
+ test("status exposes presence and exact schema without identity", () => {
181
+ const status = telemetryStatusMetadata({
182
+ enabled: true,
183
+ source: "config",
184
+ categories: { usage: true, reliability: false, adoption: true },
185
+ installId: "private"
186
+ }, { provider: "posthog", host: "https://us.i.posthog.com", configured: false });
187
+ assert.deepEqual(status, {
188
+ enabled: true,
189
+ source: "config",
190
+ categories: { usage: true, reliability: false, adoption: true },
191
+ installIdPresent: true,
192
+ destination: { provider: "posthog", host: "https://us.i.posthog.com", configured: false },
193
+ schema: TELEMETRY_SCHEMA_INVENTORY
49
194
  });
195
+ assert.equal("installId" in status, false);
50
196
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-telemetry-core",
3
3
  "private": false,
4
- "version": "0.16.4",
4
+ "version": "0.16.6",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -26,7 +26,7 @@
26
26
  "provenance": true
27
27
  },
28
28
  "dependencies": {
29
- "@velum-labs/routekit-runtime": "0.16.4"
29
+ "@velum-labs/routekit-runtime": "0.16.6"
30
30
  },
31
31
  "keywords": [
32
32
  "routekit",