@velum-labs/routekit-daemon 0.16.5 → 0.16.7

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,4 +1,5 @@
1
1
  import type { ServiceRecord } from "@velum-labs/routekit-runtime";
2
+ import { type TelemetryTransportFactory } from "./telemetry.js";
2
3
  export declare const ROUTEKIT_DAEMON_KIND = "daemon";
3
4
  export declare const ROUTEKIT_PRODUCT = "routekit";
4
5
  export type RouteKitDaemonOptions = {
@@ -13,6 +14,9 @@ export type RouteKitDaemonOptions = {
13
14
  portless?: boolean;
14
15
  drainGraceMs?: number;
15
16
  onShutdownRequested?: (reason: "stop" | "restart" | "upgrade") => void;
17
+ /** Test seam for a network-free telemetry transport. */
18
+ telemetryTransportFactory?: TelemetryTransportFactory;
19
+ telemetryFlushIntervalMs?: number;
16
20
  /** Test seam used by child-process interruption coverage. */
17
21
  onAccountTransactionPhase?: (phase: "prepared" | "credentials-written" | "router-swapped" | "committed") => void;
18
22
  };
package/dist/index.js CHANGED
@@ -15,12 +15,13 @@ import { resolveLeaderboardConfig, startSwitchingGatewayProxy } from "@velum-lab
15
15
  import { accountKindForCliproxyAuthType, PROVIDERS, resolveAccountConnector } from "@velum-labs/routekit-registry";
16
16
  import { startRouter } from "@velum-labs/routekit-router";
17
17
  import { acquireLifecycleLock, CONTROL_PROTOCOL_VERSION, ControlClient, ControlError, createPortlessSession, createServiceRecordStore, createTokenStore, encodeJoinCredential, extendCleanupGrace, generateControlToken, nextServiceGeneration, processIdentity, registerCleanup, SERVICE_HOME_MODE, startControlServer, supervisorFromEnv, writeFileAtomic } from "@velum-labs/routekit-runtime";
18
- import { createConsentManager } from "@velum-labs/routekit-telemetry-core";
18
+ import { createConsentManager, durationBucket, TELEMETRY_SCHEMA_INVENTORY, telemetryStatusMetadata } from "@velum-labs/routekit-telemetry-core";
19
19
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
20
20
  import { cleanupAccountTransaction, markAccountTransactionCommitted, prepareAccountTransaction, recoverAccountTransactions, rollbackAccountTransaction } from "./account-transaction.js";
21
21
  import { CallAttributionStore, callInspection } from "./call-attribution-store.js";
22
22
  import { createCliproxySidecar } from "./cliproxy-sidecar.js";
23
23
  import { aggregateInspections, buildLeaderboardResult, defaultLeaderboardWindow, LeaderboardRollupStore } from "./leaderboard.js";
24
+ import { DaemonTelemetry, DEFAULT_TELEMETRY_HOST, GatewayTelemetryAggregator } from "./telemetry.js";
24
25
  export const ROUTEKIT_DAEMON_KIND = "daemon";
25
26
  export const ROUTEKIT_PRODUCT = "routekit";
26
27
  function dataTokenPath(home) {
@@ -275,6 +276,8 @@ export async function startRouteKitDaemon(options) {
275
276
  let sidecarRef;
276
277
  let activeRouter;
277
278
  let accountActivity;
279
+ let daemonTelemetry;
280
+ let gatewayTelemetry;
278
281
  let record;
279
282
  let closed = false;
280
283
  let draining = false;
@@ -326,6 +329,24 @@ export async function startRouteKitDaemon(options) {
326
329
  home,
327
330
  config: leaderboardConfig
328
331
  });
332
+ const telemetry = createConsentManager({
333
+ path: () => join(home, "telemetry.json"),
334
+ environmentVariable: "ROUTEKIT_TELEMETRY"
335
+ });
336
+ daemonTelemetry = new DaemonTelemetry({
337
+ env,
338
+ resolveConsent: telemetry.resolve,
339
+ ...(options.telemetryTransportFactory !== undefined
340
+ ? { factory: options.telemetryTransportFactory }
341
+ : {})
342
+ });
343
+ gatewayTelemetry = new GatewayTelemetryAggregator({
344
+ telemetry: daemonTelemetry,
345
+ version: options.packageVersion,
346
+ ...(options.telemetryFlushIntervalMs !== undefined
347
+ ? { flushIntervalMs: options.telemetryFlushIntervalMs }
348
+ : {})
349
+ });
329
350
  // Independent of leaderboard durable rollups: last-selection only.
330
351
  mkdirSync(join(home, "usage"), { recursive: true, mode: 0o700 });
331
352
  accountActivity = new AccountActivityCoordinator({
@@ -348,6 +369,7 @@ export async function startRouteKitDaemon(options) {
348
369
  const inspection = callInspection(record);
349
370
  if (inspection !== undefined)
350
371
  leaderboardRollups.record(inspection);
372
+ gatewayTelemetry?.record(record);
351
373
  }
352
374
  };
353
375
  const wantsCliproxySidecar = (config) => config.providers["cliproxy"] !== undefined;
@@ -469,9 +491,10 @@ export async function startRouteKitDaemon(options) {
469
491
  sources: ["global"]
470
492
  });
471
493
  let handlers;
472
- const telemetry = createConsentManager({
473
- path: () => join(home, "telemetry.json"),
474
- environmentVariable: "ROUTEKIT_TELEMETRY"
494
+ const telemetryStatus = () => telemetryStatusMetadata(telemetry.resolve(env), {
495
+ provider: "posthog",
496
+ host: env.ROUTEKIT_POSTHOG_HOST?.trim() || DEFAULT_TELEMETRY_HOST,
497
+ configured: (env.ROUTEKIT_POSTHOG_KEY ?? "").trim().length > 0
475
498
  });
476
499
  handlers = {
477
500
  "daemon.status": async () => ({
@@ -1288,16 +1311,69 @@ export async function startRouteKitDaemon(options) {
1288
1311
  throw new ControlError({ code: "internal", message });
1289
1312
  }
1290
1313
  },
1291
- "telemetry.get": async () => ({ enabled: telemetry.resolve().enabled }),
1314
+ "telemetry.get": async () => telemetryStatus(),
1292
1315
  "telemetry.set": async (params) => {
1293
1316
  await serializeMutation(async () => {
1294
- if (params.enabled)
1295
- telemetry.enable();
1296
- else
1297
- telemetry.disable();
1317
+ if (params.enabled === false) {
1318
+ if (telemetry.resolve(env).enabled) {
1319
+ gatewayTelemetry?.flush();
1320
+ await daemonTelemetry?.flush();
1321
+ await daemonTelemetry?.shutdown();
1322
+ }
1323
+ else {
1324
+ await daemonTelemetry?.discard();
1325
+ }
1326
+ gatewayTelemetry?.discard();
1327
+ }
1328
+ if (params.enabled !== undefined) {
1329
+ if (params.enabled)
1330
+ telemetry.enable();
1331
+ else
1332
+ telemetry.disable();
1333
+ }
1334
+ if (params.category !== undefined && params.categoryEnabled !== undefined) {
1335
+ if (!params.categoryEnabled &&
1336
+ (params.category === "usage" || params.category === "reliability")) {
1337
+ gatewayTelemetry?.discard(params.category);
1338
+ }
1339
+ telemetry.setCategory(params.category, params.categoryEnabled);
1340
+ }
1341
+ const result = telemetry.resolve(env);
1342
+ if (result.enabled && result.categories.adoption) {
1343
+ daemonTelemetry?.capture("routekit.telemetry_preference_changed", {
1344
+ action: params.enabled !== undefined ? "master" : "category",
1345
+ ...(params.category !== undefined ? { category: params.category } : {}),
1346
+ enabled: params.enabled ?? params.categoryEnabled,
1347
+ source: result.source,
1348
+ version: options.packageVersion
1349
+ });
1350
+ }
1298
1351
  });
1299
- return { enabled: telemetry.resolve().enabled };
1352
+ return telemetryStatus();
1300
1353
  },
1354
+ "telemetry.resetIdentity": async () => {
1355
+ await serializeMutation(async () => {
1356
+ gatewayTelemetry?.flush();
1357
+ await daemonTelemetry?.flush();
1358
+ await daemonTelemetry?.shutdown();
1359
+ gatewayTelemetry?.discard();
1360
+ telemetry.resetIdentity(env);
1361
+ const result = telemetry.resolve(env);
1362
+ if (result.enabled && result.categories.adoption) {
1363
+ daemonTelemetry?.capture("routekit.telemetry_preference_changed", {
1364
+ action: "identity-reset",
1365
+ enabled: true,
1366
+ source: result.source,
1367
+ version: options.packageVersion
1368
+ });
1369
+ }
1370
+ });
1371
+ return telemetryStatus();
1372
+ },
1373
+ "telemetry.schema": async () => TELEMETRY_SCHEMA_INVENTORY,
1374
+ "telemetry.captureCommand": async (params) => ({
1375
+ accepted: daemonTelemetry?.capture("routekit.command_completed", params) ?? false
1376
+ }),
1301
1377
  "doctor.run": async (_params, context) => {
1302
1378
  const providers = await activeRouter.providerStatuses(context.signal);
1303
1379
  const configuredProviders = configuredProviderIds(currentConfig);
@@ -1444,8 +1520,53 @@ export async function startRouteKitDaemon(options) {
1444
1520
  }
1445
1521
  }
1446
1522
  };
1523
+ const operationFor = (method, params) => {
1524
+ switch (method) {
1525
+ case "daemon.reload":
1526
+ return "config_reload";
1527
+ case "config.update":
1528
+ return "config_update";
1529
+ case "config.import":
1530
+ return "config_import";
1531
+ case "providers.set":
1532
+ return params.enabled === true
1533
+ ? "provider_enable"
1534
+ : "provider_disable";
1535
+ case "accounts.enroll":
1536
+ return "account_enroll";
1537
+ case "accounts.enrollActivate":
1538
+ return "account_enroll_activate";
1539
+ case "accounts.remove":
1540
+ return "account_remove";
1541
+ case "accounts.sync":
1542
+ return "account_sync";
1543
+ case "launcher.prepare":
1544
+ return "launcher_prepare";
1545
+ case "tokens.issue":
1546
+ return "token_issue";
1547
+ case "tokens.revoke":
1548
+ return "token_revoke";
1549
+ default:
1550
+ return undefined;
1551
+ }
1552
+ };
1553
+ const captureOperation = (method, params, outcome, durationMs) => {
1554
+ const operation = operationFor(method, params);
1555
+ if (operation === undefined)
1556
+ return;
1557
+ daemonTelemetry?.capture("routekit.product_operation_completed", {
1558
+ operation,
1559
+ outcome,
1560
+ duration_bucket: durationBucket(durationMs),
1561
+ version: options.packageVersion
1562
+ });
1563
+ };
1564
+ const dispatch = createRouteKitControlHandler(handlers, {
1565
+ onCommitted: (method, params, durationMs) => captureOperation(method, params, "success", durationMs),
1566
+ onControlError: (method, params, _code, durationMs) => captureOperation(method, params, "error", durationMs)
1567
+ });
1447
1568
  control = await startControlServer({
1448
- handler: createRouteKitControlHandler(handlers),
1569
+ handler: dispatch,
1449
1570
  token: generateControlToken(),
1450
1571
  product: ROUTEKIT_PRODUCT,
1451
1572
  packageVersion: options.packageVersion,
@@ -1500,6 +1621,14 @@ export async function startRouteKitDaemon(options) {
1500
1621
  dataPort: proxy.port(),
1501
1622
  startedAt
1502
1623
  });
1624
+ daemonTelemetry.capture("routekit.daemon_lifecycle", {
1625
+ action: "started",
1626
+ outcome: "success",
1627
+ supervisor: ["systemd", "launchd", "detached"].includes(supervisorFromEnv(env))
1628
+ ? supervisorFromEnv(env)
1629
+ : "unknown",
1630
+ version: options.packageVersion
1631
+ });
1503
1632
  extendCleanupGrace(drainGraceMs + 10_000);
1504
1633
  let closeRun;
1505
1634
  const close = () => {
@@ -1509,6 +1638,16 @@ export async function startRouteKitDaemon(options) {
1509
1638
  lifecycle = "quiescing";
1510
1639
  draining = true;
1511
1640
  await mutationTail;
1641
+ gatewayTelemetry?.close();
1642
+ daemonTelemetry?.capture("routekit.daemon_lifecycle", {
1643
+ action: "stopped",
1644
+ outcome: "success",
1645
+ supervisor: ["systemd", "launchd", "detached"].includes(supervisorFromEnv(env))
1646
+ ? supervisorFromEnv(env)
1647
+ : "unknown",
1648
+ version: options.packageVersion
1649
+ });
1650
+ await daemonTelemetry?.shutdown();
1512
1651
  lifecycle = "draining";
1513
1652
  await proxy?.drain(drainGraceMs);
1514
1653
  await activeRouter?.close();
@@ -1547,6 +1686,8 @@ export async function startRouteKitDaemon(options) {
1547
1686
  };
1548
1687
  }
1549
1688
  catch (error) {
1689
+ gatewayTelemetry?.close();
1690
+ await daemonTelemetry?.shutdown();
1550
1691
  await proxy?.close();
1551
1692
  await activeRouter?.close();
1552
1693
  accountActivity?.close();
@@ -0,0 +1,62 @@
1
+ import type { ModelCallRecord } from "@velum-labs/routekit-gateway";
2
+ import { type ConsentDecision, type TelemetryCategory, type TelemetryEventName, type TelemetryEventProperties } from "@velum-labs/routekit-telemetry-core";
3
+ export declare const DEFAULT_TELEMETRY_HOST = "https://us.i.posthog.com";
4
+ export declare const DEFAULT_TELEMETRY_FLUSH_INTERVAL_MS: number;
5
+ export declare const DEFAULT_TELEMETRY_GROUP_LIMIT = 256;
6
+ export declare const DEFAULT_TELEMETRY_SHUTDOWN_TIMEOUT_MS = 2000;
7
+ export type TelemetryTransportPayload = {
8
+ distinctId: string;
9
+ event: string;
10
+ properties: Record<string, unknown>;
11
+ disableGeoip: true;
12
+ };
13
+ export type TelemetryTransportClient = {
14
+ capture(payload: TelemetryTransportPayload): void;
15
+ flush(): Promise<void>;
16
+ optOut?(): Promise<void> | void;
17
+ shutdown(timeoutMs: number): Promise<void> | void;
18
+ };
19
+ export type TelemetryTransportFactory = (key: string, options: {
20
+ host: string;
21
+ flushAt: number;
22
+ flushInterval: number;
23
+ maxQueueSize: number;
24
+ }) => TelemetryTransportClient;
25
+ export type DaemonTelemetryOptions = {
26
+ env: NodeJS.ProcessEnv;
27
+ resolveConsent: (env: NodeJS.ProcessEnv) => ConsentDecision;
28
+ factory?: TelemetryTransportFactory;
29
+ shutdownTimeoutMs?: number;
30
+ };
31
+ /** Daemon-owned, consent-gated transport. All payloads pass through the schema builder. */
32
+ export declare class DaemonTelemetry {
33
+ #private;
34
+ constructor(options: DaemonTelemetryOptions);
35
+ capture<N extends TelemetryEventName>(name: N, properties: TelemetryEventProperties[N]): boolean;
36
+ permitted(category: TelemetryCategory): boolean;
37
+ flush(): Promise<void>;
38
+ shutdown(): Promise<void>;
39
+ resetTransport(): Promise<void>;
40
+ discard(): Promise<void>;
41
+ }
42
+ type IntervalHandle = ReturnType<typeof setInterval>;
43
+ export type GatewayTelemetryAggregatorOptions = {
44
+ telemetry: Pick<DaemonTelemetry, "capture" | "permitted">;
45
+ version: string;
46
+ groupLimit?: number;
47
+ flushIntervalMs?: number;
48
+ setInterval?: (callback: () => void, ms: number) => IntervalHandle;
49
+ clearInterval?: (handle: IntervalHandle) => void;
50
+ };
51
+ /** Bounded, in-memory gateway summaries; never retains raw call records. */
52
+ export declare class GatewayTelemetryAggregator {
53
+ #private;
54
+ constructor(options: GatewayTelemetryAggregatorOptions);
55
+ record(record: ModelCallRecord): void;
56
+ flush(): void;
57
+ size(): number;
58
+ /** Drop buffered summaries for an opted-out family without sending them later. */
59
+ discard(category?: "usage" | "reliability"): void;
60
+ close(): void;
61
+ }
62
+ export {};
@@ -0,0 +1,352 @@
1
+ import { boundedShutdown, buildTelemetryEvent } from "@velum-labs/routekit-telemetry-core";
2
+ import { PostHog } from "posthog-node";
3
+ export const DEFAULT_TELEMETRY_HOST = "https://us.i.posthog.com";
4
+ export const DEFAULT_TELEMETRY_FLUSH_INTERVAL_MS = 60 * 60 * 1_000;
5
+ export const DEFAULT_TELEMETRY_GROUP_LIMIT = 256;
6
+ export const DEFAULT_TELEMETRY_SHUTDOWN_TIMEOUT_MS = 2_000;
7
+ const postHogFactory = (key, options) => {
8
+ const client = new PostHog(key, {
9
+ host: options.host,
10
+ flushAt: options.flushAt,
11
+ flushInterval: options.flushInterval,
12
+ maxQueueSize: options.maxQueueSize,
13
+ persistence: "memory",
14
+ enableLocalEvaluation: false,
15
+ enableExceptionAutocapture: false
16
+ });
17
+ return {
18
+ capture: (payload) => client.capture(payload),
19
+ flush: async () => await client.flush(),
20
+ optOut: async () => await client.optOut(),
21
+ shutdown: async (timeoutMs) => await client.shutdown(timeoutMs)
22
+ };
23
+ };
24
+ /** Daemon-owned, consent-gated transport. All payloads pass through the schema builder. */
25
+ export class DaemonTelemetry {
26
+ #env;
27
+ #resolveConsent;
28
+ #factory;
29
+ #shutdownTimeoutMs;
30
+ #client;
31
+ #clientKey;
32
+ #clientHost;
33
+ constructor(options) {
34
+ this.#env = options.env;
35
+ this.#resolveConsent = options.resolveConsent;
36
+ this.#factory = options.factory ?? postHogFactory;
37
+ this.#shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_TELEMETRY_SHUTDOWN_TIMEOUT_MS;
38
+ }
39
+ capture(name, properties) {
40
+ let built;
41
+ try {
42
+ built = buildTelemetryEvent(name, properties);
43
+ const consent = this.#resolveConsent(this.#env);
44
+ if (!consent.enabled ||
45
+ !consent.categories[built.category] ||
46
+ consent.installId === undefined) {
47
+ if (!consent.enabled || consent.installId === undefined)
48
+ void this.#retireTransport(false);
49
+ return false;
50
+ }
51
+ const client = this.#transport();
52
+ if (client === undefined)
53
+ return false;
54
+ client.capture({
55
+ distinctId: consent.installId,
56
+ event: built.event,
57
+ properties: built.properties,
58
+ disableGeoip: true
59
+ });
60
+ return true;
61
+ }
62
+ catch {
63
+ return false;
64
+ }
65
+ }
66
+ permitted(category) {
67
+ const consent = this.#resolveConsent(this.#env);
68
+ const permitted = consent.enabled &&
69
+ consent.categories[category] &&
70
+ consent.installId !== undefined &&
71
+ this.#key() !== undefined;
72
+ if (!consent.enabled || consent.installId === undefined)
73
+ void this.#retireTransport(false);
74
+ return permitted;
75
+ }
76
+ async flush() {
77
+ try {
78
+ const consent = this.#resolveConsent(this.#env);
79
+ if (!consent.enabled || consent.installId === undefined) {
80
+ await this.#retireTransport(false);
81
+ return;
82
+ }
83
+ await boundedShutdown(async () => await this.#client?.flush(), this.#shutdownTimeoutMs);
84
+ }
85
+ catch {
86
+ // Telemetry cannot affect product paths.
87
+ }
88
+ }
89
+ async shutdown() {
90
+ await this.#retireTransport();
91
+ }
92
+ async resetTransport() {
93
+ await this.shutdown();
94
+ }
95
+ async discard() {
96
+ await this.#retireTransport(false);
97
+ }
98
+ #key() {
99
+ const key = this.#env.ROUTEKIT_POSTHOG_KEY?.trim();
100
+ return key === undefined || key.length === 0 ? undefined : key;
101
+ }
102
+ #retireTransport(flush = true) {
103
+ const client = this.#client;
104
+ this.#client = undefined;
105
+ this.#clientKey = undefined;
106
+ this.#clientHost = undefined;
107
+ if (client === undefined)
108
+ return Promise.resolve();
109
+ return boundedShutdown(async () => {
110
+ if (!flush)
111
+ await client.optOut?.();
112
+ await client.shutdown(this.#shutdownTimeoutMs);
113
+ }, this.#shutdownTimeoutMs);
114
+ }
115
+ #transport() {
116
+ const key = this.#key();
117
+ if (key === undefined) {
118
+ void this.#retireTransport();
119
+ return undefined;
120
+ }
121
+ const host = this.#env.ROUTEKIT_POSTHOG_HOST?.trim() || DEFAULT_TELEMETRY_HOST;
122
+ if (this.#client !== undefined && this.#clientKey === key && this.#clientHost === host)
123
+ return this.#client;
124
+ if (this.#client !== undefined)
125
+ void this.#retireTransport();
126
+ this.#client = this.#factory(key, {
127
+ host,
128
+ flushAt: 10,
129
+ flushInterval: 10_000,
130
+ maxQueueSize: 100
131
+ });
132
+ this.#clientKey = key;
133
+ this.#clientHost = host;
134
+ return this.#client;
135
+ }
136
+ }
137
+ function object(value) {
138
+ return typeof value === "object" && value !== null && !Array.isArray(value)
139
+ ? value
140
+ : undefined;
141
+ }
142
+ function finite(value) {
143
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
144
+ }
145
+ function tokenBucket(value) {
146
+ const count = finite(value);
147
+ if (count === undefined)
148
+ return "unknown";
149
+ if (count === 0)
150
+ return "0";
151
+ if (count < 1_000)
152
+ return "1-1k";
153
+ if (count < 10_000)
154
+ return "1k-10k";
155
+ if (count < 100_000)
156
+ return "10k-100k";
157
+ return ">100k";
158
+ }
159
+ function retryBucket(value) {
160
+ const count = finite(value) ?? 0;
161
+ if (count <= 0)
162
+ return "0";
163
+ if (count === 1)
164
+ return "1";
165
+ if (count === 2)
166
+ return "2";
167
+ return "3+";
168
+ }
169
+ function countBucket(count) {
170
+ if (count === 1)
171
+ return "1";
172
+ if (count <= 5)
173
+ return "2-5";
174
+ if (count <= 20)
175
+ return "6-20";
176
+ return ">20";
177
+ }
178
+ function latencyBucket(ms) {
179
+ const value = finite(ms) ?? 0;
180
+ if (value < 1_000)
181
+ return "<1s";
182
+ if (value < 10_000)
183
+ return "1-10s";
184
+ if (value < 60_000)
185
+ return "10-60s";
186
+ if (value < 300_000)
187
+ return "1-5m";
188
+ if (value < 1_800_000)
189
+ return "5-30m";
190
+ return ">30m";
191
+ }
192
+ function dimensions(record) {
193
+ const metadata = object(record.metadata);
194
+ const attribution = object(metadata?.attribution);
195
+ const provider = typeof attribution?.provider === "string" ? attribution.provider : undefined;
196
+ const model = typeof attribution?.effective_model === "string" ? attribution.effective_model : undefined;
197
+ const rawDialect = metadata?.dialect;
198
+ const dialect = rawDialect === "openai-chat" ||
199
+ rawDialect === "openai-responses" ||
200
+ rawDialect === "anthropic-messages" ||
201
+ rawDialect === "openai-embeddings"
202
+ ? rawDialect
203
+ : undefined;
204
+ if (provider === undefined || model === undefined || dialect === undefined)
205
+ return undefined;
206
+ const requestKind = dialect === "openai-chat"
207
+ ? "chat"
208
+ : dialect === "openai-responses"
209
+ ? "responses"
210
+ : dialect === "anthropic-messages"
211
+ ? "messages"
212
+ : "embeddings";
213
+ const rawBilling = attribution?.billing_mode;
214
+ const billingMode = rawBilling === "api_key"
215
+ ? "metered-api"
216
+ : rawBilling === "subscription"
217
+ ? "subscription"
218
+ : rawBilling === "client_auth"
219
+ ? "upstream-managed"
220
+ : "unknown";
221
+ const usage = object(record.usage);
222
+ return {
223
+ provider,
224
+ model,
225
+ dialect,
226
+ request_kind: requestKind,
227
+ stream: metadata?.stream === true,
228
+ billing_mode: billingMode,
229
+ outcome: record.status === "succeeded" ? "success" : "error",
230
+ latency_bucket: latencyBucket(record.latency_ms),
231
+ retry_bucket: retryBucket(attribution?.retries),
232
+ input_token_bucket: tokenBucket(usage?.prompt_tokens),
233
+ output_token_bucket: tokenBucket(usage?.completion_tokens),
234
+ failover: (finite(attribution?.account_failovers) ?? 0) > 0
235
+ };
236
+ }
237
+ /** Bounded, in-memory gateway summaries; never retains raw call records. */
238
+ export class GatewayTelemetryAggregator {
239
+ #telemetry;
240
+ #version;
241
+ #groupLimit;
242
+ #flushIntervalMs;
243
+ #setInterval;
244
+ #clearInterval;
245
+ #groups = new Map();
246
+ #timer;
247
+ constructor(options) {
248
+ this.#telemetry = options.telemetry;
249
+ this.#version = options.version;
250
+ this.#groupLimit = options.groupLimit ?? DEFAULT_TELEMETRY_GROUP_LIMIT;
251
+ this.#flushIntervalMs = options.flushIntervalMs ?? DEFAULT_TELEMETRY_FLUSH_INTERVAL_MS;
252
+ this.#setInterval = options.setInterval ?? setInterval;
253
+ this.#clearInterval = options.clearInterval ?? clearInterval;
254
+ }
255
+ record(record) {
256
+ try {
257
+ if (!this.#telemetry.permitted("usage") && !this.#telemetry.permitted("reliability"))
258
+ return;
259
+ const value = dimensions(record);
260
+ if (value === undefined)
261
+ return;
262
+ const key = JSON.stringify(value);
263
+ const current = this.#groups.get(key);
264
+ if (current !== undefined)
265
+ current.count += 1;
266
+ else if (this.#groups.size < this.#groupLimit)
267
+ this.#groups.set(key, { dimensions: value, count: 1, enqueued: {} });
268
+ this.#ensureTimer();
269
+ }
270
+ catch {
271
+ // Aggregation cannot affect gateway calls.
272
+ }
273
+ }
274
+ flush() {
275
+ const usagePermitted = this.#telemetry.permitted("usage");
276
+ const reliabilityPermitted = this.#telemetry.permitted("reliability");
277
+ if (!usagePermitted && !reliabilityPermitted) {
278
+ this.discard();
279
+ return;
280
+ }
281
+ for (const [key, entry] of this.#groups) {
282
+ const common = {
283
+ provider: entry.dimensions.provider,
284
+ model: entry.dimensions.model,
285
+ dialect: entry.dimensions.dialect,
286
+ request_kind: entry.dimensions.request_kind,
287
+ stream: entry.dimensions.stream,
288
+ request_count_bucket: countBucket(entry.count),
289
+ version: this.#version
290
+ };
291
+ const usageCaptured = !usagePermitted ||
292
+ entry.enqueued.usage === true ||
293
+ this.#telemetry.capture("routekit.gateway_usage_summary", {
294
+ ...common,
295
+ billing_mode: entry.dimensions.billing_mode,
296
+ input_token_bucket: entry.dimensions.input_token_bucket,
297
+ output_token_bucket: entry.dimensions.output_token_bucket
298
+ });
299
+ if (usagePermitted && usageCaptured)
300
+ entry.enqueued.usage = true;
301
+ const reliabilityCaptured = !reliabilityPermitted ||
302
+ entry.enqueued.reliability === true ||
303
+ this.#telemetry.capture("routekit.gateway_reliability_summary", {
304
+ ...common,
305
+ outcome: entry.dimensions.outcome,
306
+ latency_bucket: entry.dimensions.latency_bucket,
307
+ retry_bucket: entry.dimensions.retry_bucket,
308
+ failover: entry.dimensions.failover
309
+ });
310
+ if (reliabilityPermitted && reliabilityCaptured)
311
+ entry.enqueued.reliability = true;
312
+ if (usageCaptured && reliabilityCaptured)
313
+ this.#groups.delete(key);
314
+ }
315
+ if (this.#groups.size === 0)
316
+ this.#stopTimer();
317
+ }
318
+ size() {
319
+ return this.#groups.size;
320
+ }
321
+ /** Drop buffered summaries for an opted-out family without sending them later. */
322
+ discard(category) {
323
+ if (category === undefined) {
324
+ this.#groups.clear();
325
+ }
326
+ else {
327
+ for (const [key, entry] of this.#groups) {
328
+ entry.enqueued[category] = true;
329
+ if (entry.enqueued.usage === true && entry.enqueued.reliability === true) {
330
+ this.#groups.delete(key);
331
+ }
332
+ }
333
+ }
334
+ if (this.#groups.size === 0)
335
+ this.#stopTimer();
336
+ }
337
+ close() {
338
+ this.#stopTimer();
339
+ this.flush();
340
+ }
341
+ #ensureTimer() {
342
+ if (this.#timer !== undefined)
343
+ return;
344
+ this.#timer = this.#setInterval(() => this.flush(), this.#flushIntervalMs);
345
+ this.#timer.unref?.();
346
+ }
347
+ #stopTimer() {
348
+ if (this.#timer !== undefined)
349
+ this.#clearInterval(this.#timer);
350
+ this.#timer = undefined;
351
+ }
352
+ }
@@ -1345,3 +1345,61 @@ test("daemon recovers interrupted activation before loading config or starting r
1345
1345
  test("daemon recovers interrupted Claude activation before loading config or starting routers", async () => {
1346
1346
  await assertInterruptedNativeActivationRecovery("claude-code");
1347
1347
  });
1348
+ test("daemon telemetry emits lifecycle and committed operations exactly once without parameters", async () => {
1349
+ const root = mkdtempSync(join(tmpdir(), "routekit-daemon-telemetry-"));
1350
+ const stateHome = join(root, "state");
1351
+ const configPath = join(root, "router.yaml");
1352
+ mkdirSync(stateHome, { recursive: true });
1353
+ writeFileSync(join(stateHome, "telemetry.json"), JSON.stringify({
1354
+ enabled: true,
1355
+ installId: "telemetry-test-install",
1356
+ categories: { usage: true, reliability: true, adoption: true }
1357
+ }));
1358
+ writeFileSync(configPath, "providers:\n openai: {}\ndefaultModel: openai/mock-model\n");
1359
+ const upstream = await mockProvider();
1360
+ const payloads = [];
1361
+ const daemon = await startRouteKitDaemon({
1362
+ packageVersion: "1.2.3",
1363
+ stateHome,
1364
+ configPath,
1365
+ port: 0,
1366
+ portless: false,
1367
+ env: {
1368
+ ...process.env,
1369
+ HOME: root,
1370
+ ROUTEKIT_HOME: stateHome,
1371
+ OPENAI_API_KEY: "unique-api-key-canary",
1372
+ OPENAI_BASE_URL: upstream.url,
1373
+ ROUTEKIT_POSTHOG_KEY: "test-key",
1374
+ ROUTEKIT_PORTLESS: "0"
1375
+ },
1376
+ telemetryTransportFactory: () => ({
1377
+ capture: (payload) => payloads.push(payload),
1378
+ flush: async () => undefined,
1379
+ shutdown: async () => undefined
1380
+ })
1381
+ });
1382
+ try {
1383
+ const client = new RouteKitControlClient({
1384
+ url: daemon.record.url,
1385
+ token: daemon.record.controlToken
1386
+ });
1387
+ const snapshot = await client.call("config.get", {});
1388
+ const params = {
1389
+ expectedRevision: snapshot.revision,
1390
+ document: "providers:\n openai: {}\ndefaultModel: openai/mock-model\n"
1391
+ };
1392
+ await client.call("config.update", params, { idempotencyKey: "telemetry-once" });
1393
+ await client.call("config.update", params, { idempotencyKey: "telemetry-once" });
1394
+ assert.equal(payloads.filter((item) => item.event === "routekit.product_operation_completed").length, 1);
1395
+ const serialized = JSON.stringify(payloads);
1396
+ assert.doesNotMatch(serialized, /unique-api-key-canary|OPENAI_BASE_URL|expectedRevision|document|telemetry-once/);
1397
+ assert.equal(payloads.filter((item) => item.event === "routekit.daemon_lifecycle" && item.properties.action === "started").length, 1);
1398
+ }
1399
+ finally {
1400
+ await daemon.close();
1401
+ await upstream.close();
1402
+ assert.equal(payloads.filter((item) => item.event === "routekit.daemon_lifecycle" && item.properties.action === "stopped").length, 1);
1403
+ rmSync(root, { recursive: true, force: true });
1404
+ }
1405
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,493 @@
1
+ import assert from "node:assert/strict";
2
+ import { createServer } from "node:http";
3
+ import test from "node:test";
4
+ import { gunzipSync, inflateSync } from "node:zlib";
5
+ import { DaemonTelemetry, GatewayTelemetryAggregator } from "../telemetry.js";
6
+ function consent(enabled = true, categories = {}) {
7
+ return {
8
+ enabled,
9
+ source: "config",
10
+ categories: { usage: true, reliability: true, adoption: true, ...categories },
11
+ ...(enabled ? { installId: "stable-install" } : {})
12
+ };
13
+ }
14
+ function record(overrides = {}) {
15
+ return {
16
+ call_id: "forbidden-call-id",
17
+ endpoint_id: "forbidden-endpoint",
18
+ model: "openai/gpt-5.2",
19
+ request_hash: "forbidden-request-hash",
20
+ response_hash: "forbidden-response-hash",
21
+ messages: [{ role: "user", content: "forbidden-body" }],
22
+ status: "succeeded",
23
+ side_effects: "none",
24
+ started_at: "2026-07-28T12:34:56.789Z",
25
+ latency_ms: 1_500,
26
+ usage: { prompt_tokens: 2_000, completion_tokens: 20 },
27
+ metadata: {
28
+ dialect: "openai-responses",
29
+ stream: true,
30
+ attribution: {
31
+ provider: "openai",
32
+ effective_model: "openai/gpt-5.2",
33
+ billing_mode: "api_key",
34
+ retries: 1,
35
+ account_failovers: 1,
36
+ principal: { token_id: "forbidden-principal" },
37
+ account: { seat: "forbidden-account" }
38
+ },
39
+ raw_error: "forbidden-error",
40
+ cost_estimate_usd: 12.3456789
41
+ },
42
+ ...overrides
43
+ };
44
+ }
45
+ function fixture(decision = consent(), env = { ROUTEKIT_POSTHOG_KEY: "key" }) {
46
+ const payloads = [];
47
+ let created = 0;
48
+ let flushed = 0;
49
+ let shutdown = 0;
50
+ const client = {
51
+ capture: (payload) => {
52
+ payloads.push(payload);
53
+ },
54
+ flush: async () => {
55
+ flushed += 1;
56
+ },
57
+ shutdown: async () => {
58
+ shutdown += 1;
59
+ }
60
+ };
61
+ const telemetry = new DaemonTelemetry({
62
+ env,
63
+ resolveConsent: () => decision,
64
+ factory: () => {
65
+ created += 1;
66
+ return client;
67
+ },
68
+ shutdownTimeoutMs: 20
69
+ });
70
+ return {
71
+ telemetry,
72
+ payloads,
73
+ created: () => created,
74
+ flushed: () => flushed,
75
+ shutdown: () => shutdown
76
+ };
77
+ }
78
+ test("transport stays absent while disabled, DNT-equivalent, category-disabled, or unconfigured", () => {
79
+ for (const [decision, env] of [
80
+ [consent(false), { ROUTEKIT_POSTHOG_KEY: "key" }],
81
+ [consent(true, { reliability: false }), { ROUTEKIT_POSTHOG_KEY: "key" }],
82
+ [consent(), {}]
83
+ ]) {
84
+ const item = fixture(decision, env);
85
+ assert.equal(item.telemetry.capture("routekit.daemon_lifecycle", {
86
+ action: "started",
87
+ outcome: "success",
88
+ supervisor: "unknown",
89
+ version: "test"
90
+ }), false);
91
+ assert.equal(item.created(), 0);
92
+ }
93
+ });
94
+ test("transport payload has stable identity and mandatory privacy flags", () => {
95
+ const item = fixture();
96
+ assert.equal(item.telemetry.capture("routekit.daemon_lifecycle", {
97
+ action: "started",
98
+ outcome: "success",
99
+ supervisor: "unknown",
100
+ version: "test"
101
+ }), true);
102
+ assert.equal(item.telemetry.capture("routekit.daemon_lifecycle", {
103
+ action: "reloaded",
104
+ outcome: "success",
105
+ supervisor: "unknown",
106
+ version: "test"
107
+ }), true);
108
+ assert.equal(item.created(), 1);
109
+ assert.deepEqual(item.payloads.map((payload) => payload.distinctId), ["stable-install", "stable-install"]);
110
+ assert.equal(item.payloads[0]?.disableGeoip, true);
111
+ assert.equal(item.payloads[0]?.properties.$process_person_profile, false);
112
+ assert.equal(item.payloads[0]?.properties.$ip, null);
113
+ });
114
+ test("real PostHog transport serializes one anonymous privacy-hardened batch", async () => {
115
+ const requests = [];
116
+ const server = createServer((request, response) => {
117
+ const chunks = [];
118
+ request.on("data", (chunk) => chunks.push(chunk));
119
+ request.on("end", () => {
120
+ let body = Buffer.concat(chunks);
121
+ if (request.headers["content-encoding"] === "gzip")
122
+ body = gunzipSync(body);
123
+ if (request.headers["content-encoding"] === "deflate")
124
+ body = inflateSync(body);
125
+ requests.push({ url: request.url, body: body.toString("utf8") });
126
+ response.writeHead(200, { "content-type": "application/json" });
127
+ response.end("{}");
128
+ });
129
+ });
130
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
131
+ const port = server.address().port;
132
+ const telemetry = new DaemonTelemetry({
133
+ env: {
134
+ ROUTEKIT_POSTHOG_KEY: "phc_local_test",
135
+ ROUTEKIT_POSTHOG_HOST: `http://127.0.0.1:${port}`
136
+ },
137
+ resolveConsent: () => consent()
138
+ });
139
+ try {
140
+ assert.equal(telemetry.capture("routekit.command_completed", {
141
+ command: "status",
142
+ cli_version: "0.17.0",
143
+ os: "darwin",
144
+ arch: "arm64",
145
+ node_major: "22",
146
+ duration_bucket: "<1s",
147
+ outcome: "success",
148
+ exit_kind: "success",
149
+ is_ci: false,
150
+ target_kind: "local"
151
+ }), true);
152
+ await telemetry.flush();
153
+ await telemetry.shutdown();
154
+ }
155
+ finally {
156
+ await new Promise((resolve) => server.close(() => resolve()));
157
+ }
158
+ assert.equal(requests.length, 1);
159
+ assert.equal(requests[0]?.url, "/batch/");
160
+ const batch = JSON.parse(requests[0].body);
161
+ const event = batch.batch[0];
162
+ assert.equal(event.event, "routekit.command_completed");
163
+ assert.equal(event.distinct_id, "stable-install");
164
+ assert.equal(event.properties.$process_person_profile, false);
165
+ assert.equal(event.properties.$ip, null);
166
+ assert.equal(event.properties.$geoip_disable, true);
167
+ assert.doesNotMatch(JSON.stringify(event), /prompt|argv|cwd|stack|api_key/i);
168
+ });
169
+ test("out-of-band consent revoke discards the real PostHog queue without a request", async () => {
170
+ let requests = 0;
171
+ const server = createServer((request, response) => {
172
+ requests += 1;
173
+ request.resume();
174
+ response.end("{}");
175
+ });
176
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
177
+ const port = server.address().port;
178
+ let decision = consent();
179
+ const telemetry = new DaemonTelemetry({
180
+ env: {
181
+ ROUTEKIT_POSTHOG_KEY: "phc_local_test",
182
+ ROUTEKIT_POSTHOG_HOST: `http://127.0.0.1:${port}`
183
+ },
184
+ resolveConsent: () => decision
185
+ });
186
+ try {
187
+ assert.equal(telemetry.capture("routekit.daemon_lifecycle", {
188
+ action: "started",
189
+ outcome: "success",
190
+ supervisor: "unknown",
191
+ version: "test"
192
+ }), true);
193
+ decision = consent(false);
194
+ assert.equal(telemetry.capture("routekit.daemon_lifecycle", {
195
+ action: "stopped",
196
+ outcome: "success",
197
+ supervisor: "unknown",
198
+ version: "test"
199
+ }), false);
200
+ await new Promise((resolve) => setImmediate(resolve));
201
+ }
202
+ finally {
203
+ await telemetry.shutdown();
204
+ await new Promise((resolve) => server.close(() => resolve()));
205
+ }
206
+ assert.equal(requests, 0);
207
+ });
208
+ test("transport failures are isolated and shutdown is bounded", async () => {
209
+ const telemetry = new DaemonTelemetry({
210
+ env: { ROUTEKIT_POSTHOG_KEY: "key" },
211
+ resolveConsent: () => consent(),
212
+ factory: () => ({
213
+ capture: () => {
214
+ throw new Error("capture failed");
215
+ },
216
+ flush: async () => {
217
+ throw new Error("flush failed");
218
+ },
219
+ shutdown: async () => await new Promise(() => undefined)
220
+ }),
221
+ shutdownTimeoutMs: 20
222
+ });
223
+ assert.equal(telemetry.capture("routekit.daemon_lifecycle", {
224
+ action: "started",
225
+ outcome: "success",
226
+ supervisor: "unknown",
227
+ version: "test"
228
+ }), false);
229
+ const started = Date.now();
230
+ await telemetry.shutdown();
231
+ assert.ok(Date.now() - started < 200);
232
+ });
233
+ test("key transitions retire stale transports and consent denial never flushes implicitly", async () => {
234
+ const env = { ROUTEKIT_POSTHOG_KEY: "key-one" };
235
+ let decision = consent();
236
+ const clients = [];
237
+ const telemetry = new DaemonTelemetry({
238
+ env,
239
+ resolveConsent: () => decision,
240
+ factory: () => {
241
+ const state = { shutdowns: 0, optOuts: 0 };
242
+ clients.push(state);
243
+ return {
244
+ capture: () => undefined,
245
+ flush: async () => undefined,
246
+ optOut: async () => {
247
+ state.optOuts += 1;
248
+ },
249
+ shutdown: async () => {
250
+ state.shutdowns += 1;
251
+ }
252
+ };
253
+ }
254
+ });
255
+ assert.equal(telemetry.capture("routekit.daemon_lifecycle", {
256
+ action: "started",
257
+ outcome: "success",
258
+ supervisor: "unknown",
259
+ version: "test"
260
+ }), true);
261
+ env.ROUTEKIT_POSTHOG_KEY = "key-two";
262
+ assert.equal(telemetry.capture("routekit.daemon_lifecycle", {
263
+ action: "reloaded",
264
+ outcome: "success",
265
+ supervisor: "unknown",
266
+ version: "test"
267
+ }), true);
268
+ await new Promise((resolve) => setImmediate(resolve));
269
+ assert.equal(clients[0]?.shutdowns, 1);
270
+ decision = consent(false);
271
+ assert.equal(telemetry.capture("routekit.daemon_lifecycle", {
272
+ action: "stopped",
273
+ outcome: "success",
274
+ supervisor: "unknown",
275
+ version: "test"
276
+ }), false);
277
+ await new Promise((resolve) => setImmediate(resolve));
278
+ assert.equal(clients[1]?.optOuts, 1);
279
+ assert.equal(clients[1]?.shutdowns, 1);
280
+ await telemetry.shutdown();
281
+ assert.equal(clients[1]?.shutdowns, 1);
282
+ assert.equal(clients.length, 2);
283
+ });
284
+ test("aggregator groups locally and emits no per-call event", () => {
285
+ const item = fixture();
286
+ const aggregator = new GatewayTelemetryAggregator({
287
+ telemetry: item.telemetry,
288
+ version: "0.17.0",
289
+ flushIntervalMs: 60_000
290
+ });
291
+ aggregator.record(record());
292
+ aggregator.record(record({ call_id: "another-sensitive-id" }));
293
+ assert.equal(item.payloads.length, 0);
294
+ assert.equal(aggregator.size(), 1);
295
+ aggregator.flush();
296
+ assert.equal(item.payloads.length, 2);
297
+ assert.deepEqual(item.payloads.map((payload) => payload.event), ["routekit.gateway_usage_summary", "routekit.gateway_reliability_summary"]);
298
+ assert.equal(item.payloads[0]?.properties.request_count_bucket, "2-5");
299
+ assert.equal("outcome" in item.payloads[0].properties, false);
300
+ assert.equal("latency_bucket" in item.payloads[0].properties, false);
301
+ assert.equal("input_token_bucket" in item.payloads[1].properties, false);
302
+ assert.equal("billing_mode" in item.payloads[1].properties, false);
303
+ assert.equal(aggregator.size(), 0);
304
+ assert.doesNotMatch(JSON.stringify(item.payloads), /forbidden|12\.3456789|2026-07-28/);
305
+ aggregator.close();
306
+ });
307
+ test("aggregator bounds cardinality and keeps groups when enqueue fails", () => {
308
+ const successful = fixture();
309
+ const aggregator = new GatewayTelemetryAggregator({
310
+ telemetry: successful.telemetry,
311
+ version: "0.17.0",
312
+ groupLimit: 2
313
+ });
314
+ aggregator.record(record());
315
+ aggregator.record(record({
316
+ metadata: {
317
+ ...record().metadata,
318
+ attribution: {
319
+ ...record().metadata?.attribution,
320
+ effective_model: "openai/gpt-5.3"
321
+ }
322
+ }
323
+ }));
324
+ aggregator.record(record({
325
+ metadata: {
326
+ ...record().metadata,
327
+ attribution: {
328
+ ...record().metadata?.attribution,
329
+ effective_model: "openai/gpt-5.4"
330
+ }
331
+ }
332
+ }));
333
+ assert.equal(aggregator.size(), 2);
334
+ aggregator.close();
335
+ const failing = new GatewayTelemetryAggregator({
336
+ telemetry: { permitted: () => true, capture: () => false },
337
+ version: "0.17.0"
338
+ });
339
+ failing.record(record());
340
+ failing.flush();
341
+ assert.equal(failing.size(), 1);
342
+ failing.close();
343
+ });
344
+ test("aggregator gates collection and each payload family on live categories", () => {
345
+ const disabled = new GatewayTelemetryAggregator({
346
+ telemetry: fixture(consent(false)).telemetry,
347
+ version: "0.17.0"
348
+ });
349
+ disabled.record(record());
350
+ assert.equal(disabled.size(), 0);
351
+ disabled.close();
352
+ for (const [categories, expected] of [
353
+ [{ usage: true, reliability: false }, ["routekit.gateway_usage_summary"]],
354
+ [{ usage: false, reliability: true }, ["routekit.gateway_reliability_summary"]]
355
+ ]) {
356
+ const item = fixture(consent(true, categories));
357
+ const aggregator = new GatewayTelemetryAggregator({
358
+ telemetry: item.telemetry,
359
+ version: "0.17.0"
360
+ });
361
+ aggregator.record(record());
362
+ aggregator.flush();
363
+ assert.deepEqual(item.payloads.map((payload) => payload.event), expected);
364
+ assert.equal(aggregator.size(), 0);
365
+ aggregator.close();
366
+ }
367
+ });
368
+ test("aggregator discards opted-out summaries before a category or identity can be re-enabled", () => {
369
+ const captured = [];
370
+ const aggregator = new GatewayTelemetryAggregator({
371
+ telemetry: {
372
+ permitted: () => true,
373
+ capture: (name) => {
374
+ captured.push(name);
375
+ return true;
376
+ }
377
+ },
378
+ version: "0.17.0"
379
+ });
380
+ aggregator.record(record());
381
+ aggregator.discard("usage");
382
+ aggregator.flush();
383
+ assert.deepEqual(captured, ["routekit.gateway_reliability_summary"]);
384
+ assert.equal(aggregator.size(), 0);
385
+ aggregator.record(record());
386
+ aggregator.discard();
387
+ aggregator.flush();
388
+ assert.deepEqual(captured, ["routekit.gateway_reliability_summary"]);
389
+ assert.equal(aggregator.size(), 0);
390
+ aggregator.close();
391
+ });
392
+ test("aggregator retains a group until every currently permitted family enqueues", () => {
393
+ const captured = [];
394
+ let reliabilitySucceeds = false;
395
+ const aggregator = new GatewayTelemetryAggregator({
396
+ telemetry: {
397
+ permitted: () => true,
398
+ capture: (name) => {
399
+ captured.push(name);
400
+ return name === "routekit.gateway_usage_summary" || reliabilitySucceeds;
401
+ }
402
+ },
403
+ version: "0.17.0"
404
+ });
405
+ aggregator.record(record());
406
+ aggregator.flush();
407
+ assert.equal(aggregator.size(), 1);
408
+ reliabilitySucceeds = true;
409
+ aggregator.flush();
410
+ assert.equal(aggregator.size(), 0);
411
+ assert.deepEqual(captured, [
412
+ "routekit.gateway_usage_summary",
413
+ "routekit.gateway_reliability_summary",
414
+ "routekit.gateway_reliability_summary"
415
+ ]);
416
+ aggregator.close();
417
+ });
418
+ test("serialized payloads reject adversarial sensitive canaries across all families", () => {
419
+ const item = fixture();
420
+ const families = [
421
+ [
422
+ "routekit.command_completed",
423
+ {
424
+ command: "status",
425
+ cli_version: "0.17.0",
426
+ os: "darwin",
427
+ arch: "arm64",
428
+ node_major: "22",
429
+ duration_bucket: "<1s",
430
+ outcome: "success",
431
+ exit_kind: "success",
432
+ is_ci: false,
433
+ target_kind: "local"
434
+ }
435
+ ],
436
+ [
437
+ "routekit.product_operation_completed",
438
+ { operation: "config_update", outcome: "error", duration_bucket: "<1s", version: "0.17.0" }
439
+ ],
440
+ [
441
+ "routekit.daemon_lifecycle",
442
+ { action: "started", outcome: "success", supervisor: "detached", version: "0.17.0" }
443
+ ],
444
+ [
445
+ "routekit.telemetry_preference_changed",
446
+ { action: "master", enabled: true, source: "config", version: "0.17.0" }
447
+ ]
448
+ ];
449
+ for (const [name, properties] of families)
450
+ item.telemetry.capture(name, properties);
451
+ const aggregator = new GatewayTelemetryAggregator({
452
+ telemetry: item.telemetry,
453
+ version: "0.17.0"
454
+ });
455
+ aggregator.record(record({
456
+ metadata: {
457
+ ...record().metadata,
458
+ prompt: "forbidden-prompt",
459
+ source_path: "/Users/private/source.ts",
460
+ api_key: "sk-secret",
461
+ oauth_token: "oauth-secret",
462
+ account_id: "acct-secret",
463
+ principal_label: "private-user",
464
+ request_body: "raw-body",
465
+ raw_error: "stack trace",
466
+ exact_cost: 9.87654321,
467
+ exact_usage: 2345,
468
+ exact_time: "2026-07-28T12:34:56Z"
469
+ }
470
+ }));
471
+ aggregator.flush();
472
+ const serialized = JSON.stringify(item.payloads);
473
+ for (const canary of [
474
+ "forbidden-prompt",
475
+ "/Users/private",
476
+ "sk-secret",
477
+ "oauth-secret",
478
+ "acct-secret",
479
+ "private-user",
480
+ "raw-body",
481
+ "stack trace",
482
+ "9.87654321",
483
+ "2345",
484
+ "2026-07-28T12:34:56Z",
485
+ "forbidden-call-id",
486
+ "forbidden-request-hash"
487
+ ]) {
488
+ assert.doesNotMatch(serialized, new RegExp(canary.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")));
489
+ }
490
+ assert.match(serialized, /openai\/gpt-5\.2/);
491
+ assert.match(serialized, /1k-10k/);
492
+ aggregator.close();
493
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-daemon",
3
3
  "private": false,
4
- "version": "0.16.5",
4
+ "version": "0.16.7",
5
5
  "description": "Singleton RouteKit control daemon and stable model gateway.",
6
6
  "repository": {
7
7
  "type": "git",
@@ -33,15 +33,16 @@
33
33
  "service"
34
34
  ],
35
35
  "dependencies": {
36
+ "posthog-node": "5.46.1",
36
37
  "yaml": "2.9.0",
37
- "@velum-labs/routekit-accounts": "0.16.5",
38
- "@velum-labs/routekit-config": "0.16.5",
39
- "@velum-labs/routekit-control": "0.16.5",
40
- "@velum-labs/routekit-gateway": "0.16.5",
41
- "@velum-labs/routekit-registry": "0.16.5",
42
- "@velum-labs/routekit-router": "0.16.5",
43
- "@velum-labs/routekit-runtime": "0.16.5",
44
- "@velum-labs/routekit-telemetry-core": "0.16.5"
38
+ "@velum-labs/routekit-accounts": "0.16.7",
39
+ "@velum-labs/routekit-config": "0.16.7",
40
+ "@velum-labs/routekit-control": "0.16.7",
41
+ "@velum-labs/routekit-gateway": "0.16.7",
42
+ "@velum-labs/routekit-registry": "0.16.7",
43
+ "@velum-labs/routekit-router": "0.16.7",
44
+ "@velum-labs/routekit-runtime": "0.16.7",
45
+ "@velum-labs/routekit-telemetry-core": "0.16.7"
45
46
  },
46
47
  "scripts": {
47
48
  "build": "tsc -b",