@timo972/cc-router 0.12.1 → 0.12.2-rc.0

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.
Files changed (35) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/Dockerfile +1 -0
  3. package/README.md +1 -1
  4. package/dist/cli/cmd-accounts.js +116 -65
  5. package/dist/cli/cmd-setup.js +100 -30
  6. package/dist/cli/cmd-status.js +14 -2
  7. package/dist/cli/cmd-telemetry.js +43 -32
  8. package/dist/cli/index.js +15 -1
  9. package/dist/config/directory.js +14 -0
  10. package/dist/config/telemetry.js +192 -41
  11. package/dist/providers/anthropic/usage-refresher.js +35 -1
  12. package/dist/providers/model-discovery.js +17 -11
  13. package/dist/providers/openai/device-oauth.js +88 -31
  14. package/dist/providers/openai/token-refresher.js +12 -2
  15. package/dist/providers/openai/usage-fetch.js +19 -2
  16. package/dist/proxy/anthropic-messages-route.js +144 -5
  17. package/dist/proxy/anthropic-proxy.js +10 -0
  18. package/dist/proxy/anthropic-response-capture.js +6 -19
  19. package/dist/proxy/openai-ingress.js +98 -1
  20. package/dist/proxy/server.js +35 -22
  21. package/dist/proxy/token-refresher.js +12 -2
  22. package/dist/proxy/usage-capture.js +41 -4
  23. package/dist/telemetry/contracts.js +129 -0
  24. package/dist/telemetry/facade.js +654 -0
  25. package/dist/telemetry/otel-exporters.js +289 -0
  26. package/dist/telemetry/posthog-client.js +398 -0
  27. package/dist/telemetry/privacy.js +567 -0
  28. package/dist/telemetry/runtime.js +306 -0
  29. package/dist/telemetry/setup-diagnostics.js +239 -0
  30. package/dist/utils/token-extractor.js +79 -11
  31. package/dist/utils/token-validator.js +25 -9
  32. package/docs/README.md +34 -0
  33. package/docs/telemetry.md +167 -0
  34. package/package.json +12 -2
  35. package/dist/utils/telemetry.js +0 -88
@@ -0,0 +1,654 @@
1
+ import { randomUUID as nodeRandomUUID } from "node:crypto";
2
+ import { context, ROOT_CONTEXT, SpanKind, SpanStatusCode, TraceFlags, trace, } from "@opentelemetry/api";
3
+ import { logs, SeverityNumber } from "@opentelemetry/api-logs";
4
+ import { claimTelemetryFirstStart, createTelemetryConsentGate, getTelemetrySnapshot, } from "../config/telemetry.js";
5
+ import { getCurrentVersion } from "../utils/self-update.js";
6
+ import { MAX_ACCOUNT_POOL_SIZE } from "./contracts.js";
7
+ import { createPostHogTelemetryClient } from "./posthog-client.js";
8
+ import { LOG_ATTRIBUTE_KEYS, SPAN_ATTRIBUTE_KEYS, reconstructAnalyticsEvent, sanitizeException, toOtelAttributes, } from "./privacy.js";
9
+ import { flushTelemetryRuntimeWithin, isTelemetryRuntimeActive, shutdownTelemetryRuntimeWithin, startTelemetryRuntime, } from "./runtime.js";
10
+ const HEARTBEAT_INTERVAL_MS = 60 * 60 * 1_000;
11
+ const SCOPE = "cc-router";
12
+ const PROXY_REQUEST_PATHS = new Set(["/v1/messages", "/v1/responses"]);
13
+ export function runtimeMode() {
14
+ if (process.env["CC_ROUTER_SERVICE"] === "1")
15
+ return "service";
16
+ if (process.env["CC_ROUTER_DAEMON"] === "1")
17
+ return "daemon";
18
+ return "foreground";
19
+ }
20
+ function osFamily() {
21
+ switch (process.platform) {
22
+ case "darwin": return "macos";
23
+ case "linux": return "linux";
24
+ case "win32": return "windows";
25
+ default: return "other";
26
+ }
27
+ }
28
+ export function modelFamilyOf(model) {
29
+ const value = model.toLowerCase();
30
+ if (value.includes("fable"))
31
+ return "fable";
32
+ if (value.includes("sonnet"))
33
+ return "sonnet";
34
+ if (value.includes("opus"))
35
+ return "opus";
36
+ if (value.includes("haiku"))
37
+ return "haiku";
38
+ if (value.includes("codex") || value.startsWith("gpt-"))
39
+ return "codex";
40
+ return "other";
41
+ }
42
+ export function httpFailureReason(status) {
43
+ if (status === 401)
44
+ return "unauthorized";
45
+ if (status === 403)
46
+ return "forbidden";
47
+ if (status === 429)
48
+ return "rate_limited";
49
+ if (status >= 500)
50
+ return "upstream_5xx";
51
+ return "upstream_4xx";
52
+ }
53
+ export function httpOutcome(status) {
54
+ if (status === 429)
55
+ return "rate_limited";
56
+ if (status >= 400)
57
+ return "upstream_error";
58
+ return "complete";
59
+ }
60
+ function errorProperty(error, key) {
61
+ if (typeof error !== "object" || error === null)
62
+ return undefined;
63
+ try {
64
+ return Object.getOwnPropertyDescriptor(error, key)?.value;
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ }
70
+ /** Classify only explicit, allowlisted transport failures without parsing text. */
71
+ export function classifyExpectedRuntimeFailure(error) {
72
+ const directCode = errorProperty(error, "code");
73
+ const causeCode = errorProperty(errorProperty(error, "cause"), "code");
74
+ const code = typeof directCode === "string" ? directCode : causeCode;
75
+ if (code === "ETIMEDOUT")
76
+ return "timeout";
77
+ if (["EAI_AGAIN", "ECONNREFUSED", "ECONNRESET", "ENETUNREACH", "ENOTFOUND", "EPIPE"]
78
+ .includes(String(code))) {
79
+ return "network_failure";
80
+ }
81
+ try {
82
+ if (error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")) {
83
+ return "timeout";
84
+ }
85
+ }
86
+ catch {
87
+ // Exotic thrown values remain unexpected.
88
+ }
89
+ return undefined;
90
+ }
91
+ function severityNumber(severity) {
92
+ switch (severity) {
93
+ case "info": return SeverityNumber.INFO;
94
+ case "warn": return SeverityNumber.WARN;
95
+ case "error": return SeverityNumber.ERROR;
96
+ case "fatal": return SeverityNumber.FATAL;
97
+ }
98
+ }
99
+ function isRandomUuid(value) {
100
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
101
+ }
102
+ function clampedAccountCount(value) {
103
+ if (!Number.isFinite(value))
104
+ return undefined;
105
+ return Math.max(0, Math.min(MAX_ACCOUNT_POOL_SIZE, Math.floor(value)));
106
+ }
107
+ function expectedSetupFailureOutcome(reason) {
108
+ switch (reason) {
109
+ case "unauthorized":
110
+ case "forbidden":
111
+ case "upstream_4xx":
112
+ case "upstream_5xx":
113
+ case "unexpected_response_shape":
114
+ return "upstream_error";
115
+ case "rate_limited": return "rate_limited";
116
+ case "timeout": return "timeout";
117
+ case "user_cancelled": return "cancelled";
118
+ case "other": return "other";
119
+ default: return undefined;
120
+ }
121
+ }
122
+ let sharedGate;
123
+ let sharedAnalytics;
124
+ function sharedConsentGate() {
125
+ return sharedGate ??= createTelemetryConsentGate(getTelemetrySnapshot, () => {
126
+ try {
127
+ sharedAnalytics?.discardPending();
128
+ }
129
+ catch { /* isolated */ }
130
+ });
131
+ }
132
+ /** Logs and analytics work without the proxy's tracing runtime. */
133
+ function ensureRuntime() {
134
+ try {
135
+ if (isTelemetryRuntimeActive())
136
+ return;
137
+ startTelemetryRuntime({ tracing: false, runtimeMode: runtimeMode() });
138
+ }
139
+ catch {
140
+ // Telemetry transport failures never reach application code.
141
+ }
142
+ }
143
+ function spanAttributes(operation, attributes) {
144
+ return {
145
+ "cc_router.operation": operation,
146
+ ...toOtelAttributes(SPAN_ATTRIBUTE_KEYS, attributes),
147
+ };
148
+ }
149
+ function finalizeSpan(span, status, endTimeMs) {
150
+ try {
151
+ span.setStatus({ code: status === "error" ? SpanStatusCode.ERROR : SpanStatusCode.OK });
152
+ }
153
+ catch {
154
+ // Telemetry finalization never changes application behavior.
155
+ }
156
+ try {
157
+ span.end(endTimeMs);
158
+ }
159
+ catch {
160
+ // A broken tracer must not replace the callback value or error identity.
161
+ }
162
+ }
163
+ const NOOP_SPAN_HANDLE = {
164
+ annotate: () => undefined,
165
+ end: () => undefined,
166
+ };
167
+ const NOOP_ACTIVE_SPAN = {
168
+ annotate: () => undefined,
169
+ fail: () => undefined,
170
+ };
171
+ /**
172
+ * Start a closed-schema span whose lifetime is owned by an event-driven body.
173
+ * The handle never exposes the OTel span or accepts arbitrary attributes.
174
+ */
175
+ export function startTelemetrySpan(operation, attributes) {
176
+ try {
177
+ if (!sharedConsentGate().getSnapshot())
178
+ return NOOP_SPAN_HANDLE;
179
+ const span = trace.getTracer(SCOPE).startSpan(operation, {
180
+ attributes: spanAttributes(operation, attributes),
181
+ });
182
+ let ended = false;
183
+ return {
184
+ annotate(next) {
185
+ if (ended)
186
+ return;
187
+ try {
188
+ span.setAttributes(toOtelAttributes(SPAN_ATTRIBUTE_KEYS, next));
189
+ }
190
+ catch {
191
+ // Body telemetry never changes response handling.
192
+ }
193
+ },
194
+ end(status) {
195
+ if (ended)
196
+ return;
197
+ ended = true;
198
+ finalizeSpan(span, status);
199
+ },
200
+ };
201
+ }
202
+ catch {
203
+ return NOOP_SPAN_HANDLE;
204
+ }
205
+ }
206
+ /**
207
+ * Run one closed runtime operation as the active span. The callback runs
208
+ * exactly once; a rejection or an explicit `span.fail()` marks the span failed.
209
+ */
210
+ export function withTelemetrySpan(operation, attributes, callback) {
211
+ let started = false;
212
+ try {
213
+ if (!sharedConsentGate().getSnapshot())
214
+ return callback(NOOP_ACTIVE_SPAN);
215
+ return trace.getTracer(SCOPE).startActiveSpan(operation, { attributes: spanAttributes(operation, attributes) }, async (span) => {
216
+ started = true;
217
+ let failed = false;
218
+ let outcomeSet = false;
219
+ const handle = {
220
+ annotate(next) {
221
+ if (next.outcome !== undefined)
222
+ outcomeSet = true;
223
+ try {
224
+ span.setAttributes(toOtelAttributes(SPAN_ATTRIBUTE_KEYS, next));
225
+ }
226
+ catch { /* optional */ }
227
+ },
228
+ fail(next) {
229
+ failed = true;
230
+ if (next)
231
+ handle.annotate(next);
232
+ // A generic outcome only when no failure path classified one.
233
+ if (!outcomeSet)
234
+ handle.annotate({ outcome: "upstream_error" });
235
+ },
236
+ };
237
+ try {
238
+ const value = await callback(handle);
239
+ finalizeSpan(span, failed ? "error" : "ok");
240
+ return value;
241
+ }
242
+ catch (error) {
243
+ finalizeSpan(span, "error");
244
+ throw error;
245
+ }
246
+ });
247
+ }
248
+ catch (error) {
249
+ if (started)
250
+ return Promise.reject(error);
251
+ return callback(NOOP_ACTIVE_SPAN);
252
+ }
253
+ }
254
+ export function annotateActiveSpan(operation, attributes) {
255
+ try {
256
+ if (!sharedConsentGate().getSnapshot())
257
+ return;
258
+ trace.getActiveSpan()?.setAttributes(spanAttributes(operation, attributes));
259
+ }
260
+ catch {
261
+ // Span enrichment is optional.
262
+ }
263
+ }
264
+ /** How long a closed response's span waits for the route's final verdict. */
265
+ const REQUEST_SPAN_SETTLE_GRACE_MS = 1_500;
266
+ const requestSpans = new WeakMap();
267
+ function endRequestSpan(state) {
268
+ if (state.ended)
269
+ return;
270
+ state.ended = true;
271
+ if (state.timer !== undefined)
272
+ clearTimeout(state.timer);
273
+ try {
274
+ state.span.setAttributes(toOtelAttributes(SPAN_ATTRIBUTE_KEYS, { httpStatusCode: state.response.statusCode }));
275
+ }
276
+ catch {
277
+ // Response telemetry never changes the response itself.
278
+ }
279
+ const failed = state.outcome !== undefined
280
+ ? state.outcome !== "complete"
281
+ : state.response.statusCode >= 500;
282
+ // The span ends when the response did, even if the verdict arrived later.
283
+ finalizeSpan(state.span, failed ? "error" : "ok", state.closedAt);
284
+ }
285
+ /**
286
+ * Record the route's final verdict for the request span. The HTTP status alone
287
+ * cannot tell a relayed stream that failed midway from a complete one, so the
288
+ * span stays open (bounded) until the route settles it or the grace expires.
289
+ */
290
+ export function settleProxyRequestSpan(response, attributes) {
291
+ try {
292
+ const state = requestSpans.get(response);
293
+ if (!state || state.ended)
294
+ return;
295
+ state.span.setAttributes(toOtelAttributes(SPAN_ATTRIBUTE_KEYS, attributes));
296
+ if (attributes.outcome !== undefined)
297
+ state.outcome = attributes.outcome;
298
+ state.settled = true;
299
+ if (state.closedAt !== undefined)
300
+ endRequestSpan(state);
301
+ }
302
+ catch {
303
+ // Span enrichment is optional.
304
+ }
305
+ }
306
+ /** Wrap the two inference routes in a server span; every other route passes through. */
307
+ export function telemetryRequestMiddleware() {
308
+ return (request, response, next) => {
309
+ let continued = false;
310
+ const proceed = () => {
311
+ if (continued)
312
+ return;
313
+ continued = true;
314
+ next();
315
+ };
316
+ try {
317
+ if (!PROXY_REQUEST_PATHS.has(request.path) || !sharedConsentGate().getSnapshot()) {
318
+ proceed();
319
+ return;
320
+ }
321
+ const attributes = {
322
+ ...(request.method === "GET" || request.method === "POST"
323
+ ? { httpMethod: request.method }
324
+ : {}),
325
+ route: request.path === "/v1/messages" ? "messages" : "responses",
326
+ };
327
+ trace.getTracer(SCOPE).startActiveSpan("proxy.request", { kind: SpanKind.SERVER, attributes: spanAttributes("proxy.request", attributes) }, span => {
328
+ const state = { span, response, ended: false, settled: false };
329
+ requestSpans.set(response, state);
330
+ const closed = () => {
331
+ if (state.closedAt !== undefined)
332
+ return;
333
+ state.closedAt = Date.now();
334
+ if (state.settled) {
335
+ endRequestSpan(state);
336
+ return;
337
+ }
338
+ state.timer = setTimeout(() => endRequestSpan(state), REQUEST_SPAN_SETTLE_GRACE_MS);
339
+ state.timer.unref?.();
340
+ };
341
+ response.once("finish", closed);
342
+ response.once("close", closed);
343
+ proceed();
344
+ });
345
+ }
346
+ catch {
347
+ proceed();
348
+ }
349
+ };
350
+ }
351
+ export function createTelemetryFacade(dependencies = {}) {
352
+ const now = dependencies.now ?? Date.now;
353
+ const uuid = dependencies.randomUUID ?? nodeRandomUUID;
354
+ const getSnapshot = dependencies.getSnapshot;
355
+ let gate;
356
+ // Lazy: binding the consent generation must not read the state file at import.
357
+ const consent = () => gate ??= getSnapshot
358
+ ? createTelemetryConsentGate(getSnapshot, () => {
359
+ try {
360
+ dependencies.analytics?.discardPending();
361
+ }
362
+ catch { /* isolated */ }
363
+ })
364
+ : sharedConsentGate();
365
+ const immediate = new Set();
366
+ const analytics = () => {
367
+ if (dependencies.analytics)
368
+ return dependencies.analytics;
369
+ try {
370
+ // The gate (not the raw state reader) feeds the transport so queued
371
+ // batches are dropped after an opt-out, not just new captures.
372
+ return sharedAnalytics ??= createPostHogTelemetryClient({
373
+ getSnapshot: () => consent().getSnapshot(),
374
+ });
375
+ }
376
+ catch {
377
+ return undefined;
378
+ }
379
+ };
380
+ const activeAnalytics = () => dependencies.analytics ?? sharedAnalytics;
381
+ const metadata = () => ({
382
+ serviceVersion: getCurrentVersion(),
383
+ osFamily: osFamily(),
384
+ runtimeMode: runtimeMode(),
385
+ });
386
+ const trackImmediate = (operation) => {
387
+ const contained = Promise.resolve(operation).catch(() => undefined);
388
+ immediate.add(contained);
389
+ void contained.then(() => { immediate.delete(contained); });
390
+ };
391
+ const boundedDeadline = (deadlineMs) => Number.isFinite(deadlineMs)
392
+ ? Math.max(0, Math.min(10_000, Math.floor(deadlineMs)))
393
+ : 0;
394
+ const settleWithin = async (operations, deadlineMs) => {
395
+ if (operations.length === 0)
396
+ return;
397
+ let timer;
398
+ await Promise.race([
399
+ Promise.allSettled(operations).then(() => undefined),
400
+ new Promise(resolve => {
401
+ timer = setTimeout(resolve, boundedDeadline(deadlineMs));
402
+ timer.unref?.();
403
+ }),
404
+ ]).catch(() => undefined).finally(() => clearTimeout(timer));
405
+ };
406
+ const emitLog = (body, severity, attributes) => {
407
+ try {
408
+ ensureRuntime();
409
+ const emit = () => {
410
+ logs.getLogger(SCOPE).emit({
411
+ body,
412
+ severityNumber: severityNumber(severity),
413
+ severityText: severity.toUpperCase(),
414
+ timestamp: now(),
415
+ attributes: toOtelAttributes(LOG_ATTRIBUTE_KEYS, attributes),
416
+ });
417
+ };
418
+ const active = trace.getActiveSpan()?.spanContext();
419
+ // Trace correlation would drop this record when the active span is
420
+ // unsampled, so an unsampled parent is detached instead.
421
+ if (active && (active.traceFlags & TraceFlags.SAMPLED) !== 0)
422
+ emit();
423
+ else
424
+ context.with(ROOT_CONTEXT, emit);
425
+ }
426
+ catch {
427
+ // Remote logging is best effort only.
428
+ }
429
+ };
430
+ const captureAnalytics = (snapshot, event, properties, diagnosticId) => {
431
+ try {
432
+ ensureRuntime();
433
+ const safe = reconstructAnalyticsEvent({ event, properties }, {
434
+ installationId: snapshot.state.installId,
435
+ ...(diagnosticId === undefined ? {} : { diagnosticId }),
436
+ });
437
+ const client = safe && analytics();
438
+ if (safe && client)
439
+ client.captureAnalytics(safe, snapshot.state.consentGeneration);
440
+ }
441
+ catch {
442
+ // Application behavior never depends on telemetry capture.
443
+ }
444
+ };
445
+ const recordSafeLog = (input) => {
446
+ const snapshot = consent().getSnapshot();
447
+ if (!snapshot)
448
+ return;
449
+ const { severity, diagnosticId, ...attributes } = input;
450
+ emitLog("runtime.failure", severity, {
451
+ ...attributes,
452
+ ...metadata(),
453
+ diagnosticId: diagnosticId !== undefined && isRandomUuid(diagnosticId) ? diagnosticId : undefined,
454
+ });
455
+ };
456
+ const recordUnexpectedException = (error, exceptionContext, diagnosticId) => {
457
+ const snapshot = consent().getSnapshot();
458
+ if (!snapshot)
459
+ return undefined;
460
+ try {
461
+ const candidate = diagnosticId ?? uuid();
462
+ if (!isRandomUuid(candidate) || candidate === snapshot.state.installId)
463
+ return undefined;
464
+ const exception = sanitizeException(error, exceptionContext, {
465
+ installationId: snapshot.state.installId,
466
+ diagnosticId: candidate,
467
+ });
468
+ if (!exception)
469
+ return undefined;
470
+ if (exceptionContext.category === "runtime") {
471
+ // Raw detail stays local; only the correlation key is exported.
472
+ console.error(`[cc-router] Unexpected runtime failure (diagnostic ID: ${exception.diagnosticId})`, error);
473
+ }
474
+ const client = analytics();
475
+ if (client) {
476
+ if (exceptionContext.category === "setup") {
477
+ trackImmediate(client.captureExceptionImmediate(exception, snapshot.state.consentGeneration));
478
+ }
479
+ else {
480
+ client.captureException(exception, snapshot.state.consentGeneration);
481
+ }
482
+ }
483
+ return exception.diagnosticId;
484
+ }
485
+ catch {
486
+ return undefined;
487
+ }
488
+ };
489
+ /** Neither flush nor shutdown may outlive its deadline or reject. */
490
+ const settleTelemetry = async (kind, deadlineMs) => {
491
+ try {
492
+ if (!consent().getSnapshot()) {
493
+ try {
494
+ activeAnalytics()?.discardPending();
495
+ }
496
+ catch { /* isolated */ }
497
+ }
498
+ else {
499
+ await settleWithin([...immediate], deadlineMs);
500
+ }
501
+ const client = activeAnalytics();
502
+ await Promise.all([
503
+ (kind === "flush" ? client?.flushWithin(deadlineMs) : client?.shutdownWithin(deadlineMs))
504
+ ?.catch(() => undefined),
505
+ (kind === "flush"
506
+ ? flushTelemetryRuntimeWithin(deadlineMs)
507
+ : shutdownTelemetryRuntimeWithin(deadlineMs)).catch(() => undefined),
508
+ ]);
509
+ }
510
+ catch {
511
+ // Telemetry lifecycle failures never change application behavior.
512
+ }
513
+ };
514
+ const setupProperties = (input, extra = {}) => ({ ...input, ...extra, ...metadata() });
515
+ return {
516
+ recordApplicationStart() {
517
+ try {
518
+ const current = consent().getSnapshot();
519
+ const claimed = claimTelemetryFirstStart();
520
+ if (!current || !claimed?.enabled)
521
+ return;
522
+ if (current.state.installId !== claimed.state.installId)
523
+ return;
524
+ if (current.state.consentGeneration !== claimed.state.consentGeneration)
525
+ return;
526
+ captureAnalytics(claimed, "app.first_start", metadata());
527
+ }
528
+ catch {
529
+ // First-start attribution never affects CLI startup.
530
+ }
531
+ },
532
+ recordProxyStarted(accountCount) {
533
+ const snapshot = consent().getSnapshot();
534
+ if (!snapshot)
535
+ return;
536
+ captureAnalytics(snapshot, "proxy.started", {
537
+ ...metadata(),
538
+ accountPoolSize: clampedAccountCount(accountCount),
539
+ });
540
+ },
541
+ startProxyHeartbeat(getAccountCount) {
542
+ if (!consent().getSnapshot())
543
+ return;
544
+ try {
545
+ const timer = setInterval(() => {
546
+ try {
547
+ const snapshot = consent().getSnapshot();
548
+ if (!snapshot)
549
+ return;
550
+ captureAnalytics(snapshot, "proxy.heartbeat", {
551
+ ...metadata(),
552
+ accountPoolSize: clampedAccountCount(getAccountCount()),
553
+ });
554
+ }
555
+ catch {
556
+ // Timer callbacks remain failure-isolated.
557
+ }
558
+ }, HEARTBEAT_INTERVAL_MS);
559
+ timer.unref?.();
560
+ }
561
+ catch {
562
+ // Failure to schedule telemetry must not affect proxy startup.
563
+ }
564
+ },
565
+ recordSafeLog,
566
+ recordUpstreamStatus(operation, provider, httpStatusCode, extra) {
567
+ recordSafeLog({
568
+ operation,
569
+ provider,
570
+ severity: "warn",
571
+ reason: httpFailureReason(httpStatusCode),
572
+ outcome: httpOutcome(httpStatusCode),
573
+ httpStatusCode,
574
+ attempt: extra?.attempt,
575
+ operationDurationMs: extra?.durationMs,
576
+ });
577
+ },
578
+ recordRuntimeError(error, errorContext, extra) {
579
+ const expected = classifyExpectedRuntimeFailure(error);
580
+ if (!expected) {
581
+ recordUnexpectedException(error, {
582
+ category: "runtime",
583
+ reason: "other",
584
+ operation: errorContext.operation,
585
+ provider: errorContext.provider,
586
+ runtimeMode: runtimeMode(),
587
+ });
588
+ return;
589
+ }
590
+ recordSafeLog({
591
+ operation: errorContext.operation,
592
+ provider: errorContext.provider,
593
+ severity: "error",
594
+ reason: expected,
595
+ outcome: expected === "timeout" ? "timeout" : "upstream_error",
596
+ attempt: extra?.attempt,
597
+ operationDurationMs: extra?.durationMs,
598
+ });
599
+ },
600
+ recordUnexpectedException,
601
+ recordSetupStage(input) {
602
+ const snapshot = consent().getSnapshot();
603
+ if (!snapshot)
604
+ return;
605
+ const properties = setupProperties(input);
606
+ emitLog("account.setup.diagnostic", "info", properties);
607
+ captureAnalytics(snapshot, input.stage === "attempt_start" ? "account_setup.started" : "account_setup.stage_completed", properties, input.diagnosticId);
608
+ },
609
+ recordSetupStageFailure(input) {
610
+ if (!consent().getSnapshot())
611
+ return;
612
+ const outcome = expectedSetupFailureOutcome(input.reason);
613
+ emitLog("account.setup.diagnostic", "warn", setupProperties(input, outcome === undefined ? {} : { outcome }));
614
+ },
615
+ recordSetupResult(input) {
616
+ const snapshot = consent().getSnapshot();
617
+ if (!snapshot)
618
+ return;
619
+ const cancelled = input.result === "cancelled";
620
+ const properties = setupProperties({
621
+ ...input,
622
+ stage: cancelled ? "cancellation" : "success",
623
+ reason: cancelled ? "user_cancelled" : undefined,
624
+ }, { outcome: cancelled ? "cancelled" : "complete" });
625
+ emitLog("account.setup.diagnostic", "info", properties);
626
+ captureAnalytics(snapshot, cancelled ? "account_setup.cancelled" : "account_setup.succeeded", properties, input.diagnosticId);
627
+ },
628
+ recordExpectedSetupFailure(input) {
629
+ const snapshot = consent().getSnapshot();
630
+ if (!snapshot)
631
+ return;
632
+ const outcome = expectedSetupFailureOutcome(input.reason);
633
+ const properties = setupProperties(input, outcome === undefined ? {} : { outcome });
634
+ emitLog("account.setup.diagnostic", "warn", properties);
635
+ captureAnalytics(snapshot, "account_setup.failed", properties, input.diagnosticId);
636
+ },
637
+ flushTelemetryWithin: (deadlineMs) => settleTelemetry("flush", deadlineMs),
638
+ shutdownTelemetryWithin: (deadlineMs) => settleTelemetry("shutdown", deadlineMs),
639
+ };
640
+ }
641
+ const telemetry = createTelemetryFacade();
642
+ export const recordApplicationStart = telemetry.recordApplicationStart;
643
+ export const recordProxyStarted = telemetry.recordProxyStarted;
644
+ export const startProxyHeartbeat = telemetry.startProxyHeartbeat;
645
+ export const recordSafeLog = telemetry.recordSafeLog;
646
+ export const recordUpstreamStatus = telemetry.recordUpstreamStatus;
647
+ export const recordRuntimeError = telemetry.recordRuntimeError;
648
+ export const recordUnexpectedException = telemetry.recordUnexpectedException;
649
+ export const recordSetupStage = telemetry.recordSetupStage;
650
+ export const recordSetupStageFailure = telemetry.recordSetupStageFailure;
651
+ export const recordSetupResult = telemetry.recordSetupResult;
652
+ export const recordExpectedSetupFailure = telemetry.recordExpectedSetupFailure;
653
+ export const flushTelemetryWithin = telemetry.flushTelemetryWithin;
654
+ export const shutdownTelemetryWithin = telemetry.shutdownTelemetryWithin;