@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
@@ -1,6 +1,7 @@
1
1
  import { applyCodexRateLimits } from "./account-state.js";
2
2
  import { parseCodexUsagePayload } from "./usage.js";
3
3
  import { UsageRefresher } from "../../proxy/usage-refresher.js";
4
+ import { classifyExpectedRuntimeFailure, httpOutcome, recordRuntimeError, recordUpstreamStatus, withTelemetrySpan, } from "../../telemetry/facade.js";
4
5
  /**
5
6
  * The endpoint the Codex CLI's own backend client reads rate limits from
6
7
  * (codex-rs/backend-client, ChatGPT path style). Answers a bearer-only GET
@@ -8,7 +9,17 @@ import { UsageRefresher } from "../../proxy/usage-refresher.js";
8
9
  * makes usage visible without burning a request through /codex/responses.
9
10
  */
10
11
  export const CODEX_USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
11
- export async function fetchCodexUsage(account, options = {}) {
12
+ export function fetchCodexUsage(account, options = {}) {
13
+ return withTelemetrySpan("provider.usage_refresh", { provider: "openai" }, async (span) => {
14
+ const result = await runCodexUsageFetch(account, options, span);
15
+ // Failure paths classify the span where the status or error is known;
16
+ // this only covers the ones that could not (e.g. a malformed body).
17
+ if (!result.ok)
18
+ span.fail();
19
+ return result;
20
+ });
21
+ }
22
+ async function runCodexUsageFetch(account, options, span) {
12
23
  const fetchImpl = options.fetch ?? globalThis.fetch;
13
24
  const now = options.now ?? Date.now;
14
25
  let response;
@@ -18,9 +29,15 @@ export async function fetchCodexUsage(account, options = {}) {
18
29
  signal: AbortSignal.timeout(10_000),
19
30
  });
20
31
  }
21
- catch {
32
+ catch (error) {
33
+ recordRuntimeError(error, { operation: "provider.usage_refresh", provider: "openai" });
34
+ span.fail({ outcome: classifyExpectedRuntimeFailure(error) === "timeout" ? "timeout" : "upstream_error" });
22
35
  return { ok: false, reason: "network" };
23
36
  }
37
+ if (!response.ok) {
38
+ recordUpstreamStatus("provider.usage_refresh", "openai", response.status);
39
+ span.fail({ httpStatusCode: response.status, outcome: httpOutcome(response.status) });
40
+ }
24
41
  if (response.status === 401 || response.status === 403)
25
42
  return { ok: false, reason: "auth" };
26
43
  if (!response.ok)
@@ -5,8 +5,12 @@ import { acquireRequestRoute, applyUpstreamFailureRoutingDetailed, routeFailureD
5
5
  import { EmptyPoolError, NoEligibleAccountError } from "./token-pool.js";
6
6
  import { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers.js";
7
7
  import { attachAnthropicResponseCapture } from "./anthropic-response-capture.js";
8
+ import { TRACE_CONTEXT_HEADERS } from "./anthropic-proxy.js";
8
9
  import { boundModelId, stats } from "./stats.js";
9
10
  import { logError, logRoute } from "./logger.js";
11
+ import { annotateActiveSpan, classifyExpectedRuntimeFailure, modelFamilyOf, recordRuntimeError, recordUpstreamStatus, settleProxyRequestSpan, startTelemetrySpan, } from "../telemetry/facade.js";
12
+ /** How long a provider span may wait for a compressed body's usage after the response closed. */
13
+ const USAGE_SETTLE_GRACE_MS = 1_000;
10
14
  import { MAX_UPSTREAM_ATTEMPTS, RETRY_REFRESH_TIMEOUT_MS, SAME_ACCOUNT_RETRY_DELAY_MS, boundedWait, isRetryableUpstreamStatus, retryDelay, } from "./upstream-retry.js";
11
15
  const OAUTH_BETA = "oauth-2025-04-20";
12
16
  /**
@@ -44,6 +48,8 @@ function buildUpstreamHeaders(req, target, account, bodyLength) {
44
48
  delete headers["content-length"];
45
49
  delete headers["transfer-encoding"];
46
50
  delete headers["x-api-key"];
51
+ for (const header of TRACE_CONTEXT_HEADERS)
52
+ delete headers[header];
47
53
  headers["connection"] = "close";
48
54
  headers["host"] = target.host;
49
55
  headers["authorization"] = `Bearer ${account.tokens.accessToken}`;
@@ -90,7 +96,9 @@ function forwardAttempt(opts) {
90
96
  });
91
97
  upstreamRequest.on("error", reject);
92
98
  upstreamRequest.on("timeout", () => {
93
- upstreamRequest.destroy(new Error(`Upstream request timed out after ${opts.timeoutMs}ms`));
99
+ // The `code` lets telemetry classify this as an expected timeout; the
100
+ // message and the client-facing response are unchanged.
101
+ upstreamRequest.destroy(Object.assign(new Error(`Upstream request timed out after ${opts.timeoutMs}ms`), { code: "ETIMEDOUT" }));
94
102
  });
95
103
  });
96
104
  upstreamRequest.end(opts.body);
@@ -103,7 +111,7 @@ function forwardAttempt(opts) {
103
111
  * its HTTP/1.0 accommodations, so moving /v1/messages off the generic proxy
104
112
  * changes nothing about what a client receives.
105
113
  */
106
- function relayUpstreamResponse(upstream, req, res) {
114
+ function relayUpstreamResponse(upstream, req, res, onUpstreamFailure) {
107
115
  if (req.httpVersion === "1.0") {
108
116
  delete upstream.headers["transfer-encoding"];
109
117
  upstream.headers["connection"] = req.headers["connection"] ?? "close";
@@ -122,6 +130,9 @@ function relayUpstreamResponse(upstream, req, res) {
122
130
  // tear the client connection down rather than ending it cleanly, so the
123
131
  // client sees a broken transfer instead of a silently truncated body.
124
132
  upstream.once("error", () => {
133
+ // Recorded before the downstream teardown, which would otherwise look
134
+ // like a client hang-up to the response's own close listener.
135
+ onUpstreamFailure?.();
125
136
  if (!res.writableEnded)
126
137
  res.destroy();
127
138
  });
@@ -167,8 +178,22 @@ export function mountAnthropicMessagesRoute(app, opts) {
167
178
  ? "desktop"
168
179
  : "api";
169
180
  const model = boundModelId(context?.requestedModel ?? "-");
181
+ const modelFamily = modelFamilyOf(model);
182
+ const streaming = req.body?.stream === true;
170
183
  const startedAt = now();
171
184
  stats.totalRequests++;
185
+ annotateActiveSpan("proxy.request", {
186
+ provider: "anthropic",
187
+ route: "messages",
188
+ modelFamily,
189
+ requestSource: source,
190
+ streaming,
191
+ accountPoolSize: opts.pool.getAll().length,
192
+ });
193
+ /** The request span's final verdict; the middleware ends the span from it. */
194
+ const settleRequest = (outcome, extra = {}) => {
195
+ settleProxyRequestSpan(res, { ...extra, outcome, operationDurationMs: now() - startedAt });
196
+ };
172
197
  // A client that hangs up takes the in-flight upstream attempt (and any
173
198
  // pending retry) with it. `writableEnded` guards the normal-completion
174
199
  // close; only a premature close is a disconnect.
@@ -188,6 +213,23 @@ export function mountAnthropicMessagesRoute(app, opts) {
188
213
  for (let attempt = 1;; attempt++) {
189
214
  const account = route.account;
190
215
  const attemptStartedAt = now();
216
+ const attemptSpan = startTelemetrySpan("provider.inference", {
217
+ provider: "anthropic",
218
+ route: "messages",
219
+ modelFamily,
220
+ streaming,
221
+ attempt,
222
+ });
223
+ /** Close this attempt's span exactly once, on the outcome it ended with. */
224
+ const endAttempt = (outcome, extra = {}) => {
225
+ attemptSpan.annotate({
226
+ ...extra,
227
+ outcome,
228
+ attempt,
229
+ operationDurationMs: now() - attemptStartedAt,
230
+ });
231
+ attemptSpan.end(outcome === "complete" ? "ok" : "error");
232
+ };
191
233
  req._ccAccount = account;
192
234
  logRoute(account.id, account.requestCount, Math.round((account.tokens.expiresAt - now()) / 60_000));
193
235
  const forwarded = forwardAttempt({
@@ -209,10 +251,20 @@ export function mountAnthropicMessagesRoute(app, opts) {
209
251
  // is a cancellation, not an upstream failure — there is no client
210
252
  // left to receive a 502, and the generic proxy does not log client
211
253
  // resets either.
212
- if (clientGone.signal.aborted || res.writableEnded)
254
+ if (clientGone.signal.aborted || res.writableEnded) {
255
+ endAttempt("cancelled", { streamOutcome: "cancelled" });
256
+ settleRequest("cancelled", { streamOutcome: "cancelled", attempt });
213
257
  return;
258
+ }
214
259
  const message = error instanceof Error ? error.message : String(error);
215
260
  stats.totalErrors++;
261
+ recordRuntimeError(error, { operation: "provider.inference", provider: "anthropic" }, {
262
+ attempt,
263
+ durationMs: now() - attemptStartedAt,
264
+ });
265
+ const forwardOutcome = classifyExpectedRuntimeFailure(error) === "timeout"
266
+ ? "timeout"
267
+ : "upstream_error";
216
268
  logError("proxy", 0, message);
217
269
  recordActivity({
218
270
  ts: attemptStartedAt,
@@ -234,10 +286,18 @@ export function mountAnthropicMessagesRoute(app, opts) {
234
286
  error: { type: "proxy_error", message },
235
287
  });
236
288
  }
289
+ endAttempt(forwardOutcome, { httpStatusCode: 502, streamOutcome: forwardOutcome });
290
+ settleRequest(forwardOutcome, { httpStatusCode: 502, attempt });
237
291
  return;
238
292
  }
239
293
  req.socket.setTimeout(0);
240
294
  const status = upstream.statusCode ?? 0;
295
+ if (status === 401 || status === 403 || status === 429 || status >= 500) {
296
+ recordUpstreamStatus("provider.inference", "anthropic", status, {
297
+ attempt,
298
+ durationMs: now() - attemptStartedAt,
299
+ });
300
+ }
241
301
  // Routing state changes implied by the failure — cooldowns and sticky
242
302
  // binding invalidation — run before any retry decision, so the
243
303
  // re-acquisition below already sees the failed account excluded.
@@ -358,10 +418,16 @@ export function mountAnthropicMessagesRoute(app, opts) {
358
418
  next?.release();
359
419
  release();
360
420
  upstream.destroy();
421
+ endAttempt("cancelled", { httpStatusCode: status, streamOutcome: "cancelled" });
422
+ settleRequest("cancelled", { httpStatusCode: status, streamOutcome: "cancelled", attempt });
361
423
  return;
362
424
  }
363
425
  if (next) {
364
426
  // Committed: record the failed attempt and abandon its response.
427
+ endAttempt(status === 429 ? "rate_limited" : "upstream_error", {
428
+ httpStatusCode: status,
429
+ streamOutcome: "upstream_error",
430
+ });
365
431
  entry.details = `${entry.details}:will-retry`;
366
432
  recordActivity(entry);
367
433
  upstream.destroy();
@@ -404,6 +470,8 @@ export function mountAnthropicMessagesRoute(app, opts) {
404
470
  recordActivity(entry);
405
471
  upstream.destroy();
406
472
  release();
473
+ endAttempt("cancelled", { httpStatusCode: status, streamOutcome: "cancelled" });
474
+ settleRequest("cancelled", { httpStatusCode: status, streamOutcome: "cancelled", attempt });
407
475
  return;
408
476
  }
409
477
  // The held response can die while a retry decision is pending — its
@@ -421,6 +489,8 @@ export function mountAnthropicMessagesRoute(app, opts) {
421
489
  entry.details = `${entry.details}:held-response-lost`;
422
490
  recordActivity(entry);
423
491
  release();
492
+ endAttempt("upstream_error", { httpStatusCode: 502, streamOutcome: "upstream_error" });
493
+ settleRequest("upstream_error", { httpStatusCode: 502, attempt });
424
494
  logError(account.id, 502, `upstream ${status} response was lost before it could be relayed`);
425
495
  res.status(502).json({
426
496
  type: "error",
@@ -436,8 +506,77 @@ export function mountAnthropicMessagesRoute(app, opts) {
436
506
  // usage capture; the dashboard picks the values up on its next poll —
437
507
  // same contract as the generic proxy path.
438
508
  recordActivity(entry);
439
- attachAnthropicResponseCapture(upstream, res, entry, startedAt);
440
- relayUpstreamResponse(upstream, req, res);
509
+ const outcome = status === 429 ? "rate_limited"
510
+ : status >= 400 ? "upstream_error"
511
+ : "complete";
512
+ annotateActiveSpan("proxy.request", {
513
+ httpStatusCode: status,
514
+ outcome,
515
+ attempt,
516
+ operationDurationMs: now() - startedAt,
517
+ });
518
+ // Tokens and the stream verdict are only known once the relayed body
519
+ // settles. The span waits for both the response's close and the passive
520
+ // usage capture (a compressed body decodes after close), bounded so a
521
+ // decoder that never finishes cannot keep the span open.
522
+ const contentType = String(upstream.headers["content-type"] ?? "");
523
+ const encoding = String(upstream.headers["content-encoding"] ?? "");
524
+ const isSse = contentType.includes("text/event-stream");
525
+ // Set by the relay when the provider connection failed first; a client
526
+ // hang-up also destroys the upstream, so the order of events matters.
527
+ let upstreamFailedFirst = false;
528
+ // The lifecycle tracker only reads uncompressed SSE; the usage capture's
529
+ // decoded copy reports the terminal event for compressed streams.
530
+ let decodedMessageStop = false;
531
+ let responseClosed = false;
532
+ let usageSettled = false;
533
+ let usageDeadline;
534
+ const finishAttempt = () => {
535
+ if (usageDeadline !== undefined)
536
+ clearTimeout(usageDeadline);
537
+ const lifecycle = entry.streamLifecycle;
538
+ // A client hang-up destroys the upstream request too, so the client's
539
+ // own signal must win over the resulting upstream abort.
540
+ const streamOutcome = status >= 400 ? "upstream_error"
541
+ : upstreamFailedFirst ? "upstream_error"
542
+ : clientGone.signal.aborted ? "cancelled"
543
+ : lifecycle?.upstreamAborted ? "upstream_error"
544
+ : !res.writableEnded ? "cancelled"
545
+ : isSse && !lifecycle?.sawMessageStop && !decodedMessageStop ? "upstream_error"
546
+ : "complete";
547
+ const attemptOutcome = status >= 400 ? outcome
548
+ : streamOutcome === "complete" ? outcome
549
+ : streamOutcome;
550
+ const tokens = {
551
+ ...(entry.inputTokens !== undefined ? { inputTokens: entry.inputTokens } : {}),
552
+ ...(entry.outputTokens !== undefined ? { outputTokens: entry.outputTokens } : {}),
553
+ };
554
+ endAttempt(attemptOutcome, { httpStatusCode: status, streamOutcome, ...tokens });
555
+ settleRequest(attemptOutcome, { httpStatusCode: status, streamOutcome, attempt, ...tokens });
556
+ };
557
+ const maybeFinishAttempt = () => {
558
+ if (responseClosed && usageSettled)
559
+ finishAttempt();
560
+ };
561
+ res.once("close", () => {
562
+ responseClosed = true;
563
+ if (!usageSettled) {
564
+ usageDeadline = setTimeout(finishAttempt, USAGE_SETTLE_GRACE_MS);
565
+ usageDeadline.unref?.();
566
+ }
567
+ maybeFinishAttempt();
568
+ });
569
+ attachAnthropicResponseCapture(upstream, res, entry, startedAt, {
570
+ onMessageStop: () => { decodedMessageStop = true; },
571
+ onUsageSettled: () => {
572
+ usageSettled = true;
573
+ maybeFinishAttempt();
574
+ },
575
+ });
576
+ relayUpstreamResponse(upstream, req, res, () => {
577
+ if (!clientGone.signal.aborted)
578
+ upstreamFailedFirst = true;
579
+ });
441
580
  return;
442
581
  }
443
582
  };
@@ -1,4 +1,10 @@
1
1
  import { createProxyMiddleware } from "http-proxy-middleware";
2
+ /**
3
+ * Inbound trace context is stripped rather than forwarded, by every Anthropic
4
+ * relay: telemetry never joins a client's distributed trace, and a client's
5
+ * context headers must not reach the upstream provider through this proxy.
6
+ */
7
+ export const TRACE_CONTEXT_HEADERS = ["traceparent", "tracestate", "baggage"];
2
8
  /**
3
9
  * Construct the Anthropic transport with http-proxy-middleware's native
4
10
  * response piping. In particular, this deliberately does not self-handle,
@@ -15,6 +21,10 @@ export function createAnthropicProxy(options) {
15
21
  on: {
16
22
  ...options.on,
17
23
  proxyReq: (proxyRequest, request, response, proxyOptions) => {
24
+ // Telemetry never joins a distributed trace: a client's context headers
25
+ // must not reach the upstream provider through this proxy.
26
+ for (const header of TRACE_CONTEXT_HEADERS)
27
+ proxyRequest.removeHeader(header);
18
28
  proxyRequest.once("response", () => {
19
29
  proxyRequest.setTimeout(0);
20
30
  request.socket.setTimeout(0);
@@ -1,25 +1,7 @@
1
1
  import { applyAnthropicInputUsage, applyAnthropicOutputUsage } from "./stats.js";
2
2
  import { createAnthropicUsageCapture } from "./usage-capture.js";
3
3
  import { createStreamLifecycleTracker } from "./stream-lifecycle.js";
4
- /**
5
- * Attach the passive observability taps to a relayed Anthropic response:
6
- * stream-lifecycle tracking and token-usage capture, both mutating the
7
- * already-recorded activity entry in place (the dashboard picks the values
8
- * up on its next poll).
9
- *
10
- * SSE streams carry usage across two events (message_start → input/cache
11
- * tokens, message_delta → output tokens); non-streaming JSON carries all
12
- * fields in one usage object. The proxy is byte-transparent and the client's
13
- * accept-encoding makes upstream compress, so the capture decompresses its
14
- * own copy of the stream (see usage-capture.ts) — previously compressed
15
- * responses were skipped, which in practice was EVERY response.
16
- *
17
- * Shared by the generic /v1 proxy and the retrying /v1/messages transport so
18
- * the two Anthropic relays can never drift on what they observe. Must run
19
- * BEFORE the response is piped to the client, in the same synchronous block,
20
- * so no data event can slip past the taps.
21
- */
22
- export function attachAnthropicResponseCapture(upstream, downstream, entry, startedAt) {
4
+ export function attachAnthropicResponseCapture(upstream, downstream, entry, startedAt, hooks = {}) {
23
5
  const contentType = String(upstream.headers["content-type"] ?? "");
24
6
  const encoding = String(upstream.headers["content-encoding"] ?? "");
25
7
  const isCompressed = /gzip|br|deflate/.test(encoding);
@@ -32,9 +14,14 @@ export function attachAnthropicResponseCapture(upstream, downstream, entry, star
32
14
  contentEncoding: encoding,
33
15
  onInputUsage: usage => applyAnthropicInputUsage(entry, usage),
34
16
  onOutputUsage: usage => applyAnthropicOutputUsage(entry, usage),
17
+ onSettled: () => hooks.onUsageSettled?.(),
18
+ ...(hooks.onMessageStop ? { onMessageStop: hooks.onMessageStop } : {}),
35
19
  });
36
20
  if (usageCapture) {
37
21
  upstream.on("data", (chunk) => usageCapture.write(chunk));
38
22
  upstream.on("end", () => usageCapture.end());
39
23
  }
24
+ else {
25
+ hooks.onUsageSettled?.();
26
+ }
40
27
  }
@@ -7,6 +7,7 @@ import { logError, logRoute } from "./logger.js";
7
7
  import { EmptyPoolError, NoEligibleAccountError } from "./account-pool.js";
8
8
  import { acquireRequestRoute, routeReasonDetails, routeFailureDetails } from "./lease-lifecycle.js";
9
9
  import { createCorrelationId, formatTransportDiagnostic, safeCauseCode } from "./transport-diagnostics.js";
10
+ import { annotateActiveSpan, modelFamilyOf, recordRuntimeError, recordUpstreamStatus, settleProxyRequestSpan, startTelemetrySpan, } from "../telemetry/facade.js";
10
11
  import { MAX_UPSTREAM_ATTEMPTS, RETRY_REFRESH_TIMEOUT_MS, SAME_ACCOUNT_RETRY_DELAY_MS, boundedWait, isRetryableUpstreamStatus, retryDelay, } from "./upstream-retry.js";
11
12
  /**
12
13
  * Mirrors `anthropic-routing.ts`'s `requestTerminated` check. This ingress
@@ -91,6 +92,16 @@ export function mirrorUpstreamHeaders(source, apply) {
91
92
  apply(key, value);
92
93
  });
93
94
  }
95
+ /** Map the activity-log client label onto the closed telemetry request source. */
96
+ function requestSourceOf(source) {
97
+ switch (source) {
98
+ case "codex":
99
+ case "cli": return "cli";
100
+ case "desktop": return "desktop";
101
+ case "api": return "api";
102
+ default: return "other";
103
+ }
104
+ }
94
105
  /**
95
106
  * Shared OpenAI/Codex ingress lifecycle: acquire a sticky account lease,
96
107
  * refresh its token if needed, forward the request, classify the upstream
@@ -109,6 +120,18 @@ export async function runOpenAIIngress(opts) {
109
120
  // it still carries whatever model the caller asked for.
110
121
  const requestedModel = boundModelId(opts.requestedModel);
111
122
  const correlationId = createCorrelationId();
123
+ const route = path === "/v1/messages" ? "messages" : "responses";
124
+ const modelFamily = modelFamilyOf(requestedModel);
125
+ const streaming = forwardBody.stream === true;
126
+ const telemetryStartedAt = now();
127
+ annotateActiveSpan("proxy.request", {
128
+ provider: "openai",
129
+ route,
130
+ modelFamily,
131
+ requestSource: opts.requestSource ?? requestSourceOf(opts.source),
132
+ streaming,
133
+ accountPoolSize: openAIPool.getAll().length,
134
+ });
112
135
  // A client that hangs up must take the upstream request with it. Releasing
113
136
  // the lease (which the response's own close listener does) only returns the
114
137
  // account's *local* capacity — without this the Codex request keeps
@@ -259,9 +282,30 @@ export async function runOpenAIIngress(opts) {
259
282
  let upstreamFailed;
260
283
  let details;
261
284
  let accountFailureCounted;
285
+ let attemptSpan;
286
+ let attemptStartedAt = now();
287
+ let attemptCount = 0;
288
+ /** Close the attempt's span exactly once, on the outcome it ended with. */
289
+ const endAttemptSpan = (outcome, extra = {}) => {
290
+ attemptSpan?.annotate({
291
+ ...extra,
292
+ outcome,
293
+ attempt: attemptCount,
294
+ operationDurationMs: now() - attemptStartedAt,
295
+ });
296
+ attemptSpan?.end(outcome === "complete" ? "ok" : "error");
297
+ };
262
298
  for (let attempt = 1;; attempt++) {
263
299
  const account = selected.route.account;
264
- const attemptStartedAt = now();
300
+ attemptStartedAt = now();
301
+ attemptCount = attempt;
302
+ attemptSpan = startTelemetrySpan("provider.inference", {
303
+ provider: "openai",
304
+ route,
305
+ modelFamily,
306
+ streaming,
307
+ attempt,
308
+ });
265
309
  try {
266
310
  upstream = await forwardOpenAI({
267
311
  account,
@@ -281,6 +325,7 @@ export async function runOpenAIIngress(opts) {
281
325
  // pre-forward disconnect branch above, which also just releases and stops.
282
326
  if (clientGone.signal.aborted || responseTerminated(res)) {
283
327
  selected.release();
328
+ endAttemptSpan("cancelled", { streamOutcome: "cancelled" });
284
329
  return;
285
330
  }
286
331
  // A rejected forward call (network failure) must produce a local 502,
@@ -307,9 +352,30 @@ export async function runOpenAIIngress(opts) {
307
352
  headerDurationMs: now() - attemptStartedAt,
308
353
  });
309
354
  res.status(timeout ? 504 : 502).json(envelope.wrap("upstream_error", timeout ? "OpenAI request timed out" : "OpenAI request failed"));
355
+ recordRuntimeError(error, { operation: "provider.inference", provider: "openai" }, {
356
+ attempt,
357
+ durationMs: now() - attemptStartedAt,
358
+ });
359
+ endAttemptSpan(timeout ? "timeout" : "upstream_error", {
360
+ httpStatusCode: timeout ? 504 : 502,
361
+ streamOutcome: timeout ? "timeout" : "upstream_error",
362
+ });
363
+ settleProxyRequestSpan(res, {
364
+ httpStatusCode: timeout ? 504 : 502,
365
+ outcome: timeout ? "timeout" : "upstream_error",
366
+ attempt,
367
+ operationDurationMs: now() - telemetryStartedAt,
368
+ });
310
369
  return;
311
370
  }
312
371
  headerDurationMs = now() - attemptStartedAt;
372
+ if (upstream.status === 401 || upstream.status === 403
373
+ || upstream.status === 429 || upstream.status >= 500) {
374
+ recordUpstreamStatus("provider.inference", "openai", upstream.status, {
375
+ attempt,
376
+ durationMs: headerDurationMs,
377
+ });
378
+ }
313
379
  // Cooldown/eligibility react to the raw upstream signal — this must not
314
380
  // change based on how the relay later renders the response to the client.
315
381
  upstreamFailed = upstream.status === 401 || upstream.status === 429 || upstream.status >= 500;
@@ -402,6 +468,10 @@ export async function runOpenAIIngress(opts) {
402
468
  if (!prepared)
403
469
  break;
404
470
  // Committed: record the failed attempt and abandon its response.
471
+ endAttemptSpan(upstream.status === 429 ? "rate_limited" : "upstream_error", {
472
+ httpStatusCode: upstream.status,
473
+ streamOutcome: "upstream_error",
474
+ });
405
475
  stats.totalErrors++;
406
476
  recordActivity({
407
477
  ts: attemptStartedAt,
@@ -428,6 +498,12 @@ export async function runOpenAIIngress(opts) {
428
498
  await retryDelay(sameAccountDelayMs, clientGone.signal);
429
499
  if (clientGone.signal.aborted || responseTerminated(res)) {
430
500
  selected.release();
501
+ settleProxyRequestSpan(res, {
502
+ outcome: "cancelled",
503
+ streamOutcome: "cancelled",
504
+ attempt,
505
+ operationDurationMs: now() - telemetryStartedAt,
506
+ });
431
507
  return;
432
508
  }
433
509
  }
@@ -546,4 +622,25 @@ export async function runOpenAIIngress(opts) {
546
622
  entry.statusCode = finalStatus;
547
623
  entry.durationMs = now() - startedAt;
548
624
  recordActivity(entry);
625
+ const outcome = clientCancelled ? "cancelled"
626
+ : finalStatus === 429 ? "rate_limited"
627
+ : failedFinal ? "upstream_error"
628
+ : "complete";
629
+ const streamOutcome = clientCancelled ? "cancelled"
630
+ : failedFinal ? "upstream_error"
631
+ : "complete";
632
+ const tokens = {
633
+ ...(entry.inputTokens !== undefined ? { inputTokens: entry.inputTokens } : {}),
634
+ ...(entry.outputTokens !== undefined ? { outputTokens: entry.outputTokens } : {}),
635
+ };
636
+ attemptSpan?.annotate(tokens);
637
+ endAttemptSpan(outcome, { httpStatusCode: finalStatus, streamOutcome });
638
+ settleProxyRequestSpan(res, {
639
+ ...tokens,
640
+ httpStatusCode: finalStatus,
641
+ outcome,
642
+ streamOutcome,
643
+ attempt: attemptCount,
644
+ operationDurationMs: now() - telemetryStartedAt,
645
+ });
549
646
  }
@@ -9,8 +9,8 @@ import { needsRefresh, refreshAccountIfCurrent, refreshAccountsOnce, saveAccount
9
9
  import { loadAccounts, loadOpenAIAccounts, saveOpenAIAccountsToPath, accountsFileExists, readAccountsFromPath, readConfig, writeConfig, getAutoFailoverEnabled, getProxyRequestTimeoutMs, migrateLegacyAccountProviders, setProviderAccountsEnabled, upsertAccountRecord, removeAccountRecordById } from "../config/manager.js";
10
10
  import { createRefreshAllRunner, describeRefreshAll, refreshAllAccounts } from "./pool-refresh.js";
11
11
  import { checkForUpdate, performUpdate, restartSelf, printUpdateBanner, getCurrentVersion } from "../utils/self-update.js";
12
- import { trackEvent, startHeartbeat } from "../utils/telemetry.js";
13
- import { loadTelemetryState } from "../config/telemetry.js";
12
+ import { annotateActiveSpan, httpOutcome, modelFamilyOf, recordProxyStarted, runtimeMode, shutdownTelemetryWithin, startProxyHeartbeat, telemetryRequestMiddleware, } from "../telemetry/facade.js";
13
+ import { startTelemetryRuntime } from "../telemetry/runtime.js";
14
14
  import { logRoute, logError, logStartup } from "./logger.js";
15
15
  import { createLocalRoutingErrorLog, stats } from "./stats.js";
16
16
  import { applyRateLimitHeaders } from "../providers/anthropic/rate-limit-headers.js";
@@ -40,6 +40,8 @@ import { accountDeletionStatusCode, deleteAnthropicAccountTransaction, deleteOpe
40
40
  import { addOpenAIAccountTransaction } from "./account-add.js";
41
41
  import { createAnthropicRefreshMiddleware, createAnthropicRoutingMiddleware, } from "./anthropic-routing.js";
42
42
  import { createAllowanceView } from "./allowance.js";
43
+ /** Upper bound on how long a shutdown may wait for telemetry to drain. */
44
+ const TELEMETRY_SHUTDOWN_DEADLINE_MS = 1_000;
43
45
  const zeroRoutingMetrics = () => ({
44
46
  inFlightRequests: 0,
45
47
  activeSessions: 0,
@@ -334,6 +336,9 @@ export function createOpenAIPersister(accountsPath) {
334
336
  };
335
337
  }
336
338
  export async function startServer(opts = {}) {
339
+ // Best effort and non-throwing: a telemetry runtime that cannot start just
340
+ // leaves every later facade call inert.
341
+ startTelemetryRuntime({ tracing: true, runtimeMode: runtimeMode() });
337
342
  const port = opts.port ?? PROXY_PORT;
338
343
  // Direct-to-Anthropic (standalone) or via LiteLLM (full mode).
339
344
  // Priority: explicit option > LITELLM_URL env var > direct to Anthropic
@@ -1049,6 +1054,9 @@ export async function startServer(opts = {}) {
1049
1054
  const onOpenAIUpstreamAuthFailure = (account) => {
1050
1055
  refreshAndPersistOpenAIAccount(account, openAIAccounts, persistOpenAIAccounts).catch(() => { });
1051
1056
  };
1057
+ // Wraps only /v1/messages and /v1/responses in a server span; every other
1058
+ // route passes straight through.
1059
+ app.use(telemetryRequestMiddleware());
1052
1060
  mountResponsesRoutes(app, {
1053
1061
  openAIRouter,
1054
1062
  openAIPool,
@@ -1160,6 +1168,11 @@ export async function startServer(opts = {}) {
1160
1168
  const durationMs = req._startTime
1161
1169
  ? Date.now() - req._startTime
1162
1170
  : undefined;
1171
+ annotateActiveSpan("proxy.request", {
1172
+ httpStatusCode: status,
1173
+ outcome: httpOutcome(status),
1174
+ ...(durationMs !== undefined ? { operationDurationMs: durationMs } : {}),
1175
+ });
1163
1176
  // Complete the pending log entry with response info
1164
1177
  const pendingLog = req._pendingLog ?? {
1165
1178
  ts: Date.now(),
@@ -1296,6 +1309,14 @@ export async function startServer(opts = {}) {
1296
1309
  : req.headers["x-api-key"]
1297
1310
  ? "desktop"
1298
1311
  : "api";
1312
+ annotateActiveSpan("proxy.request", {
1313
+ provider: "anthropic",
1314
+ route: "messages",
1315
+ modelFamily: modelFamilyOf(req._ccRouteContext?.requestedModel ?? "-"),
1316
+ requestSource: source,
1317
+ accountPoolSize: pool.getAll().length,
1318
+ concurrency: pool.getInFlight(account.id),
1319
+ });
1299
1320
  req._pendingLog = {
1300
1321
  ts: Date.now(),
1301
1322
  accountId: account.id,
@@ -1316,7 +1337,13 @@ export async function startServer(opts = {}) {
1316
1337
  changeOrigin: true,
1317
1338
  }));
1318
1339
  // ─── Graceful shutdown ────────────────────────────────────────────────────
1319
- const shutdown = () => {
1340
+ let shuttingDown = false;
1341
+ const shutdown = async () => {
1342
+ // A second signal while the bounded telemetry flush runs must not repeat
1343
+ // the persistence work below.
1344
+ if (shuttingDown)
1345
+ return;
1346
+ shuttingDown = true;
1320
1347
  console.log(chalk.yellow("\nShutting down — saving tokens..."));
1321
1348
  usageRefresher.stop();
1322
1349
  openAIUsageRefresher.stop();
@@ -1324,10 +1351,11 @@ export async function startServer(opts = {}) {
1324
1351
  if (managesPidFile()) {
1325
1352
  removePid();
1326
1353
  }
1354
+ await shutdownTelemetryWithin(TELEMETRY_SHUTDOWN_DEADLINE_MS);
1327
1355
  process.exit(0);
1328
1356
  };
1329
- process.on("SIGTERM", shutdown);
1330
- process.on("SIGINT", shutdown);
1357
+ process.on("SIGTERM", () => { void shutdown(); });
1358
+ process.on("SIGINT", () => { void shutdown(); });
1331
1359
  // ─── Update handling ──────────────────────────────────────────────────────
1332
1360
  // Auto-update is OFF by default: installing code unattended from the npm
1333
1361
  // registry (no signature/provenance check) turns any publish-channel
@@ -1402,22 +1430,7 @@ export async function startServer(opts = {}) {
1402
1430
  console.log(autoFailover
1403
1431
  ? chalk.gray(" Auto-failover: on — 429/5xx retried across accounts before the first relayed byte")
1404
1432
  : chalk.gray(" Auto-failover: off — upstream failures pass through; clients own retries"));
1405
- // Anonymous telemetry — fire-and-forget, never blocks proxy startup.
1406
- try {
1407
- const telemetryState = loadTelemetryState();
1408
- // First-run detection: if the install is brand new, emit app_started too
1409
- const firstRunAge = Date.now() - new Date(telemetryState.firstRunAt).getTime();
1410
- if (firstRunAge < 5 * 60 * 1000) {
1411
- void trackEvent("app_started", { first_run: true });
1412
- }
1413
- void trackEvent("proxy_started", {
1414
- account_count: totalAccountCount,
1415
- mode,
1416
- });
1417
- startHeartbeat(totalAccountCount);
1418
- }
1419
- catch {
1420
- // never let telemetry break the proxy
1421
- }
1433
+ recordProxyStarted(totalAccountCount);
1434
+ startProxyHeartbeat(() => pool.getAll().length + openAIPool.getAll().length);
1422
1435
  });
1423
1436
  }