@velum-labs/routekit-daemon 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 +4 -0
- package/dist/index.js +152 -11
- package/dist/telemetry.d.ts +62 -0
- package/dist/telemetry.js +352 -0
- package/dist/test/daemon.test.js +58 -0
- package/dist/test/telemetry.test.d.ts +1 -0
- package/dist/test/telemetry.test.js +493 -0
- package/package.json +10 -9
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
|
|
473
|
-
|
|
474
|
-
|
|
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 () => (
|
|
1314
|
+
"telemetry.get": async () => telemetryStatus(),
|
|
1292
1315
|
"telemetry.set": async (params) => {
|
|
1293
1316
|
await serializeMutation(async () => {
|
|
1294
|
-
if (params.enabled)
|
|
1295
|
-
telemetry.
|
|
1296
|
-
|
|
1297
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/dist/test/daemon.test.js
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.16.6",
|
|
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.
|
|
38
|
-
"@velum-labs/routekit-config": "0.16.
|
|
39
|
-
"@velum-labs/routekit-control": "0.16.
|
|
40
|
-
"@velum-labs/routekit-gateway": "0.16.
|
|
41
|
-
"@velum-labs/routekit-registry": "0.16.
|
|
42
|
-
"@velum-labs/routekit-router": "0.16.
|
|
43
|
-
"@velum-labs/routekit-runtime": "0.16.
|
|
44
|
-
"@velum-labs/routekit-telemetry-core": "0.16.
|
|
38
|
+
"@velum-labs/routekit-accounts": "0.16.6",
|
|
39
|
+
"@velum-labs/routekit-config": "0.16.6",
|
|
40
|
+
"@velum-labs/routekit-control": "0.16.6",
|
|
41
|
+
"@velum-labs/routekit-gateway": "0.16.6",
|
|
42
|
+
"@velum-labs/routekit-registry": "0.16.6",
|
|
43
|
+
"@velum-labs/routekit-router": "0.16.6",
|
|
44
|
+
"@velum-labs/routekit-runtime": "0.16.6",
|
|
45
|
+
"@velum-labs/routekit-telemetry-core": "0.16.6"
|
|
45
46
|
},
|
|
46
47
|
"scripts": {
|
|
47
48
|
"build": "tsc -b",
|