@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.
- package/CHANGELOG.md +18 -0
- package/Dockerfile +1 -0
- package/README.md +1 -1
- package/dist/cli/cmd-accounts.js +116 -65
- package/dist/cli/cmd-setup.js +100 -30
- package/dist/cli/cmd-status.js +14 -2
- package/dist/cli/cmd-telemetry.js +43 -32
- package/dist/cli/index.js +15 -1
- package/dist/config/directory.js +14 -0
- package/dist/config/telemetry.js +192 -41
- package/dist/providers/anthropic/usage-refresher.js +35 -1
- package/dist/providers/model-discovery.js +17 -11
- package/dist/providers/openai/device-oauth.js +88 -31
- package/dist/providers/openai/token-refresher.js +12 -2
- package/dist/providers/openai/usage-fetch.js +19 -2
- package/dist/proxy/anthropic-messages-route.js +144 -5
- package/dist/proxy/anthropic-proxy.js +10 -0
- package/dist/proxy/anthropic-response-capture.js +6 -19
- package/dist/proxy/openai-ingress.js +98 -1
- package/dist/proxy/server.js +35 -22
- package/dist/proxy/token-refresher.js +12 -2
- package/dist/proxy/usage-capture.js +41 -4
- package/dist/telemetry/contracts.js +129 -0
- package/dist/telemetry/facade.js +654 -0
- package/dist/telemetry/otel-exporters.js +289 -0
- package/dist/telemetry/posthog-client.js +398 -0
- package/dist/telemetry/privacy.js +567 -0
- package/dist/telemetry/runtime.js +306 -0
- package/dist/telemetry/setup-diagnostics.js +239 -0
- package/dist/utils/token-extractor.js +79 -11
- package/dist/utils/token-validator.js +25 -9
- package/docs/README.md +34 -0
- package/docs/telemetry.md +167 -0
- package/package.json +12 -2
- package/dist/utils/telemetry.js +0 -88
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { context, propagation, trace } from "@opentelemetry/api";
|
|
4
|
+
import { logs } from "@opentelemetry/api-logs";
|
|
5
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
6
|
+
import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
|
|
7
|
+
import { BatchSpanProcessor, ParentBasedSampler, SamplingDecision, TraceIdRatioBasedSampler, } from "@opentelemetry/sdk-trace-base";
|
|
8
|
+
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
9
|
+
import { createTelemetryConsentGate, getTelemetrySnapshot, } from "../config/telemetry.js";
|
|
10
|
+
import { TELEMETRY_PATH } from "../config/paths.js";
|
|
11
|
+
import { getCurrentVersion } from "../utils/self-update.js";
|
|
12
|
+
import { createPostHogOtlpExporters } from "./otel-exporters.js";
|
|
13
|
+
import { createPostHogTelemetryClient } from "./posthog-client.js";
|
|
14
|
+
import { rebuildSanitizedException, sanitizeException } from "./privacy.js";
|
|
15
|
+
const TRACE_SAMPLE_RATIO = 0.1;
|
|
16
|
+
const QUEUE_SIZE = 100;
|
|
17
|
+
const BATCH_SIZE = 20;
|
|
18
|
+
const EXPORT_DELAY_MS = 500;
|
|
19
|
+
const EXPORT_TIMEOUT_MS = 2_000;
|
|
20
|
+
/**
|
|
21
|
+
* Fatal exceptions cannot be sent from a crashing process (Node exits as soon
|
|
22
|
+
* as the monitor returns), so the sanitized record is written here
|
|
23
|
+
* synchronously and delivered by the next start that still holds consent.
|
|
24
|
+
*/
|
|
25
|
+
const MAX_PENDING_EXCEPTIONS = 20;
|
|
26
|
+
// Resolved lazily (like the state path itself) so the module loads even when
|
|
27
|
+
// the paths module is partially mocked; every caller runs inside a try/catch.
|
|
28
|
+
function pendingExceptionsPath() {
|
|
29
|
+
return `${TELEMETRY_PATH}.pending.json`;
|
|
30
|
+
}
|
|
31
|
+
function readPendingExceptions() {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(pendingExceptionsPath(), "utf8"));
|
|
34
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function persistFatalException(exception, snapshot) {
|
|
41
|
+
const { error: _error, ...record } = exception;
|
|
42
|
+
const pending = [
|
|
43
|
+
...readPendingExceptions().slice(-(MAX_PENDING_EXCEPTIONS - 1)),
|
|
44
|
+
{ installationId: snapshot.state.installId, consentGeneration: snapshot.state.consentGeneration, exception: record },
|
|
45
|
+
];
|
|
46
|
+
const target = pendingExceptionsPath();
|
|
47
|
+
const candidate = `${target}.${process.pid}.tmp`;
|
|
48
|
+
writeFileSync(candidate, JSON.stringify(pending), { mode: 0o600 });
|
|
49
|
+
renameSync(candidate, target);
|
|
50
|
+
}
|
|
51
|
+
/** Deliver records left by a crash, only under the consent they were written with. */
|
|
52
|
+
function deliverPendingExceptions(consent, posthog) {
|
|
53
|
+
try {
|
|
54
|
+
const records = readPendingExceptions();
|
|
55
|
+
// Remove first: a crash during delivery must never resend the same records.
|
|
56
|
+
unlinkSync(pendingExceptionsPath());
|
|
57
|
+
const current = consent.getSnapshot();
|
|
58
|
+
if (!current)
|
|
59
|
+
return;
|
|
60
|
+
for (const raw of records) {
|
|
61
|
+
if (typeof raw !== "object" || raw === null)
|
|
62
|
+
continue;
|
|
63
|
+
const record = raw;
|
|
64
|
+
if (record.installationId !== current.state.installId)
|
|
65
|
+
continue;
|
|
66
|
+
// A different generation means an explicit choice happened in between.
|
|
67
|
+
if (record.consentGeneration !== current.state.consentGeneration)
|
|
68
|
+
continue;
|
|
69
|
+
const exception = rebuildSanitizedException(record.exception);
|
|
70
|
+
if (exception)
|
|
71
|
+
posthog.captureException(exception, current.state.consentGeneration);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Nothing pending, or an unreadable file: never affects startup.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Telemetry must never join or emit a distributed trace outside this process. */
|
|
79
|
+
export const noopPropagator = {
|
|
80
|
+
inject() { },
|
|
81
|
+
extract(carrierContext) {
|
|
82
|
+
return carrierContext;
|
|
83
|
+
},
|
|
84
|
+
fields() {
|
|
85
|
+
return [];
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
let activeRuntime;
|
|
89
|
+
function osFamily() {
|
|
90
|
+
switch (process.platform) {
|
|
91
|
+
case "darwin": return "macos";
|
|
92
|
+
case "linux": return "linux";
|
|
93
|
+
case "win32": return "windows";
|
|
94
|
+
default: return "other";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function cpuArchitecture() {
|
|
98
|
+
return process.arch === "arm64" || process.arch === "x64" ? process.arch : "other";
|
|
99
|
+
}
|
|
100
|
+
/** Loopback OTLP endpoints for the telemetry test suite; never read in production. */
|
|
101
|
+
function testOtlpUrls() {
|
|
102
|
+
if (process.env["NODE_ENV"] !== "test")
|
|
103
|
+
return {};
|
|
104
|
+
return {
|
|
105
|
+
traceUrl: process.env["CC_ROUTER_TEST_OTLP_TRACE_URL"],
|
|
106
|
+
logUrl: process.env["CC_ROUTER_TEST_OTLP_LOG_URL"],
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function exporterOptions(consent, options) {
|
|
110
|
+
const test = testOtlpUrls();
|
|
111
|
+
return {
|
|
112
|
+
getSnapshot: () => consent.getSnapshot(),
|
|
113
|
+
traceUrl: options.traceUrl ?? test.traceUrl,
|
|
114
|
+
logUrl: options.logUrl ?? test.logUrl,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function telemetryResource(snapshot, runtimeMode) {
|
|
118
|
+
return resourceFromAttributes({
|
|
119
|
+
"service.name": "cc-router",
|
|
120
|
+
"service.version": getCurrentVersion(),
|
|
121
|
+
"service.instance.id": snapshot.state.installId,
|
|
122
|
+
"process.runtime.version": process.versions.node,
|
|
123
|
+
"os.type": osFamily(),
|
|
124
|
+
"host.arch": cpuArchitecture(),
|
|
125
|
+
"cc_router.runtime_mode": runtimeMode,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function consentGatedSampler(consent) {
|
|
129
|
+
const delegate = new ParentBasedSampler({ root: new TraceIdRatioBasedSampler(TRACE_SAMPLE_RATIO) });
|
|
130
|
+
return {
|
|
131
|
+
shouldSample(...args) {
|
|
132
|
+
return consent.getSnapshot()
|
|
133
|
+
? delegate.shouldSample(...args)
|
|
134
|
+
: { decision: SamplingDecision.NOT_RECORD };
|
|
135
|
+
},
|
|
136
|
+
toString: () => `ConsentGated{${delegate.toString()}}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
function startTracing(consent, snapshot, options) {
|
|
140
|
+
const { spanExporter } = createPostHogOtlpExporters(exporterOptions(consent, options));
|
|
141
|
+
const provider = new NodeTracerProvider({
|
|
142
|
+
resource: telemetryResource(snapshot, options.runtimeMode),
|
|
143
|
+
sampler: consentGatedSampler(consent),
|
|
144
|
+
spanProcessors: [new BatchSpanProcessor(spanExporter, {
|
|
145
|
+
maxQueueSize: QUEUE_SIZE,
|
|
146
|
+
maxExportBatchSize: BATCH_SIZE,
|
|
147
|
+
scheduledDelayMillis: EXPORT_DELAY_MS,
|
|
148
|
+
exportTimeoutMillis: EXPORT_TIMEOUT_MS,
|
|
149
|
+
})],
|
|
150
|
+
});
|
|
151
|
+
// register() installs the AsyncLocalStorage context manager together with the
|
|
152
|
+
// global tracer provider; the inert propagator keeps trace ids off the wire.
|
|
153
|
+
provider.register({ propagator: noopPropagator });
|
|
154
|
+
trace.setGlobalTracerProvider(provider);
|
|
155
|
+
return provider;
|
|
156
|
+
}
|
|
157
|
+
function settleWithin(operation, deadlineMs) {
|
|
158
|
+
const bounded = Number.isFinite(deadlineMs) ? Math.max(0, Math.min(10_000, Math.floor(deadlineMs))) : 0;
|
|
159
|
+
let timer;
|
|
160
|
+
return Promise.race([
|
|
161
|
+
Promise.resolve().then(operation).catch(() => undefined),
|
|
162
|
+
new Promise(resolve => {
|
|
163
|
+
timer = setTimeout(resolve, bounded);
|
|
164
|
+
timer.unref?.();
|
|
165
|
+
}),
|
|
166
|
+
]).then(() => undefined).catch(() => undefined).finally(() => clearTimeout(timer));
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Start (or upgrade to tracing) the one telemetry runtime of this process.
|
|
170
|
+
* Spans are created manually through the facade; no instrumentation is loaded.
|
|
171
|
+
*/
|
|
172
|
+
export function startTelemetryRuntime(options) {
|
|
173
|
+
try {
|
|
174
|
+
const existing = activeRuntime;
|
|
175
|
+
if (existing) {
|
|
176
|
+
if (options.tracing && !existing.tracerProvider && !existing.shuttingDown) {
|
|
177
|
+
existing.tracerProvider = startTracing(existing.consent, existing.snapshot, options);
|
|
178
|
+
}
|
|
179
|
+
return !existing.shuttingDown;
|
|
180
|
+
}
|
|
181
|
+
const snapshot = getTelemetrySnapshot();
|
|
182
|
+
if (!snapshot.enabled)
|
|
183
|
+
return false;
|
|
184
|
+
let discardQueuedTelemetry = () => undefined;
|
|
185
|
+
const consent = createTelemetryConsentGate(getTelemetrySnapshot, () => discardQueuedTelemetry());
|
|
186
|
+
const { logExporter } = createPostHogOtlpExporters(exporterOptions(consent, options));
|
|
187
|
+
const logProcessor = new BatchLogRecordProcessor({
|
|
188
|
+
exporter: logExporter,
|
|
189
|
+
maxQueueSize: QUEUE_SIZE,
|
|
190
|
+
maxExportBatchSize: BATCH_SIZE,
|
|
191
|
+
scheduledDelayMillis: EXPORT_DELAY_MS,
|
|
192
|
+
exportTimeoutMillis: EXPORT_TIMEOUT_MS,
|
|
193
|
+
});
|
|
194
|
+
const posthog = createPostHogTelemetryClient({ getSnapshot: () => consent.getSnapshot() });
|
|
195
|
+
const fatalMonitor = (error) => {
|
|
196
|
+
try {
|
|
197
|
+
const current = consent.getSnapshot();
|
|
198
|
+
if (!current)
|
|
199
|
+
return;
|
|
200
|
+
const exception = sanitizeException(error, {
|
|
201
|
+
category: "runtime",
|
|
202
|
+
reason: "other",
|
|
203
|
+
runtimeMode: options.runtimeMode,
|
|
204
|
+
}, {
|
|
205
|
+
installationId: current.state.installId,
|
|
206
|
+
diagnosticId: randomUUID(),
|
|
207
|
+
});
|
|
208
|
+
if (!exception)
|
|
209
|
+
return;
|
|
210
|
+
// Node's default handler prints the Error itself; add correlation only.
|
|
211
|
+
// The process exits as soon as this monitor returns, so the record is
|
|
212
|
+
// persisted synchronously and sent by the next consenting start.
|
|
213
|
+
persistFatalException(exception, current);
|
|
214
|
+
console.error(`[cc-router] Unexpected runtime failure (diagnostic ID: ${exception.diagnosticId})`);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// The monitor observes only; it never changes Node's crash behavior.
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const runtime = {
|
|
221
|
+
loggerProvider: new LoggerProvider({
|
|
222
|
+
resource: telemetryResource(snapshot, options.runtimeMode),
|
|
223
|
+
processors: [logProcessor],
|
|
224
|
+
}),
|
|
225
|
+
posthog,
|
|
226
|
+
consent,
|
|
227
|
+
snapshot,
|
|
228
|
+
fatalMonitor,
|
|
229
|
+
exitCleanup: () => process.removeListener("uncaughtExceptionMonitor", fatalMonitor),
|
|
230
|
+
shuttingDown: false,
|
|
231
|
+
};
|
|
232
|
+
discardQueuedTelemetry = () => {
|
|
233
|
+
posthog.discardPending();
|
|
234
|
+
void logProcessor.shutdown().catch(() => undefined);
|
|
235
|
+
void runtime.tracerProvider?.shutdown().catch(() => undefined);
|
|
236
|
+
};
|
|
237
|
+
logs.setGlobalLoggerProvider(runtime.loggerProvider);
|
|
238
|
+
if (options.tracing)
|
|
239
|
+
runtime.tracerProvider = startTracing(consent, snapshot, options);
|
|
240
|
+
process.on("uncaughtExceptionMonitor", fatalMonitor);
|
|
241
|
+
process.once("exit", runtime.exitCleanup);
|
|
242
|
+
activeRuntime = runtime;
|
|
243
|
+
deliverPendingExceptions(consent, posthog);
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export function isTelemetryRuntimeActive() {
|
|
251
|
+
return activeRuntime !== undefined && !activeRuntime.shuttingDown;
|
|
252
|
+
}
|
|
253
|
+
/** True while this process runs the proxy's tracing runtime, which owns its own shutdown. */
|
|
254
|
+
export function isTelemetryTracingActive() {
|
|
255
|
+
return isTelemetryRuntimeActive() && activeRuntime?.tracerProvider !== undefined;
|
|
256
|
+
}
|
|
257
|
+
export async function flushTelemetryRuntimeWithin(deadlineMs) {
|
|
258
|
+
const runtime = activeRuntime;
|
|
259
|
+
if (!runtime || runtime.shuttingDown)
|
|
260
|
+
return;
|
|
261
|
+
try {
|
|
262
|
+
if (!runtime.consent.getSnapshot()) {
|
|
263
|
+
runtime.posthog.discardPending();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
await settleWithin(async () => {
|
|
271
|
+
await Promise.all([
|
|
272
|
+
runtime.loggerProvider.forceFlush(),
|
|
273
|
+
runtime.tracerProvider?.forceFlush(),
|
|
274
|
+
runtime.posthog.flushWithin(deadlineMs),
|
|
275
|
+
]);
|
|
276
|
+
}, deadlineMs);
|
|
277
|
+
}
|
|
278
|
+
export async function shutdownTelemetryRuntimeWithin(deadlineMs) {
|
|
279
|
+
const runtime = activeRuntime;
|
|
280
|
+
if (!runtime || runtime.shuttingDown)
|
|
281
|
+
return;
|
|
282
|
+
runtime.shuttingDown = true;
|
|
283
|
+
process.removeListener("uncaughtExceptionMonitor", runtime.fatalMonitor);
|
|
284
|
+
process.removeListener("exit", runtime.exitCleanup);
|
|
285
|
+
try {
|
|
286
|
+
if (!runtime.consent.getSnapshot())
|
|
287
|
+
runtime.posthog.discardPending();
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
runtime.posthog.discardPending();
|
|
291
|
+
}
|
|
292
|
+
await settleWithin(async () => {
|
|
293
|
+
await Promise.all([
|
|
294
|
+
runtime.loggerProvider.shutdown(),
|
|
295
|
+
runtime.tracerProvider?.shutdown(),
|
|
296
|
+
runtime.posthog.shutdownWithin(deadlineMs),
|
|
297
|
+
]);
|
|
298
|
+
}, deadlineMs);
|
|
299
|
+
logs.disable();
|
|
300
|
+
if (runtime.tracerProvider) {
|
|
301
|
+
trace.disable();
|
|
302
|
+
propagation.disable();
|
|
303
|
+
context.disable();
|
|
304
|
+
}
|
|
305
|
+
activeRuntime = undefined;
|
|
306
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { flushTelemetryWithin, recordExpectedSetupFailure, recordSetupResult, recordSetupStage, recordSetupStageFailure, recordUnexpectedException, } from "./facade.js";
|
|
3
|
+
/**
|
|
4
|
+
* A setup error keeps detailed text and its original cause local while exposing
|
|
5
|
+
* a separate, closed classification to telemetry call sites.
|
|
6
|
+
*/
|
|
7
|
+
export class SetupDiagnosticError extends Error {
|
|
8
|
+
classification;
|
|
9
|
+
constructor(localMessage, classification, options = {}) {
|
|
10
|
+
super(localMessage, options.cause === undefined ? undefined : { cause: options.cause });
|
|
11
|
+
this.name = "SetupDiagnosticError";
|
|
12
|
+
this.classification = { ...classification };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function classifyHttpSetupFailure(stage, status, localMessage, cause) {
|
|
16
|
+
let reason;
|
|
17
|
+
if (status === 401)
|
|
18
|
+
reason = "unauthorized";
|
|
19
|
+
else if (status === 403)
|
|
20
|
+
reason = "forbidden";
|
|
21
|
+
else if (status === 429)
|
|
22
|
+
reason = "rate_limited";
|
|
23
|
+
else if (status >= 400 && status < 500)
|
|
24
|
+
reason = "upstream_4xx";
|
|
25
|
+
else if (status >= 500 && status < 600)
|
|
26
|
+
reason = "upstream_5xx";
|
|
27
|
+
else
|
|
28
|
+
reason = "other";
|
|
29
|
+
return new SetupDiagnosticError(localMessage, {
|
|
30
|
+
stage,
|
|
31
|
+
reason,
|
|
32
|
+
expected: reason !== "other",
|
|
33
|
+
...(Number.isInteger(status) && status >= 100 && status <= 599
|
|
34
|
+
? { httpStatusCode: status }
|
|
35
|
+
: {}),
|
|
36
|
+
}, { cause });
|
|
37
|
+
}
|
|
38
|
+
function ownStringProperty(input, property) {
|
|
39
|
+
if ((typeof input !== "object" && typeof input !== "function") || input === null)
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, property);
|
|
43
|
+
return descriptor && "value" in descriptor && typeof descriptor.value === "string"
|
|
44
|
+
? descriptor.value
|
|
45
|
+
: undefined;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function ownCause(input) {
|
|
52
|
+
if ((typeof input !== "object" && typeof input !== "function") || input === null)
|
|
53
|
+
return undefined;
|
|
54
|
+
try {
|
|
55
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, "cause");
|
|
56
|
+
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function safeBuiltInErrorName(input) {
|
|
63
|
+
const ownName = ownStringProperty(input, "name");
|
|
64
|
+
if (ownName === "AbortError" || ownName === "TimeoutError")
|
|
65
|
+
return ownName;
|
|
66
|
+
if (typeof DOMException === "undefined")
|
|
67
|
+
return undefined;
|
|
68
|
+
try {
|
|
69
|
+
if (!(input instanceof DOMException))
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
const descriptor = Object.getOwnPropertyDescriptor(DOMException.prototype, "name");
|
|
76
|
+
if (!descriptor || typeof descriptor.get !== "function")
|
|
77
|
+
return undefined;
|
|
78
|
+
try {
|
|
79
|
+
const name = descriptor.get.call(input);
|
|
80
|
+
return name === "AbortError" || name === "TimeoutError" ? name : undefined;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const NETWORK_CODES = new Set([
|
|
87
|
+
"EAI_AGAIN",
|
|
88
|
+
"ECONNREFUSED",
|
|
89
|
+
"ECONNRESET",
|
|
90
|
+
"ENETUNREACH",
|
|
91
|
+
"ENOTFOUND",
|
|
92
|
+
"EPIPE",
|
|
93
|
+
"ETIMEDOUT",
|
|
94
|
+
]);
|
|
95
|
+
export function classifyNetworkSetupFailure(stage, cause, localMessage = cause instanceof Error ? cause.message : String(cause)) {
|
|
96
|
+
const name = safeBuiltInErrorName(cause);
|
|
97
|
+
let code;
|
|
98
|
+
let current = cause;
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (let depth = 0; depth <= 2 && current !== undefined && !seen.has(current); depth++) {
|
|
101
|
+
seen.add(current);
|
|
102
|
+
const candidate = ownStringProperty(current, "code");
|
|
103
|
+
if (candidate && NETWORK_CODES.has(candidate)) {
|
|
104
|
+
code = candidate;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
current = ownCause(current);
|
|
108
|
+
}
|
|
109
|
+
const reason = name === "AbortError" || name === "TimeoutError" || code === "ETIMEDOUT"
|
|
110
|
+
? "timeout"
|
|
111
|
+
: code && NETWORK_CODES.has(code)
|
|
112
|
+
? "network_failure"
|
|
113
|
+
: "other";
|
|
114
|
+
return new SetupDiagnosticError(localMessage, {
|
|
115
|
+
stage,
|
|
116
|
+
reason,
|
|
117
|
+
expected: reason !== "other",
|
|
118
|
+
}, { cause });
|
|
119
|
+
}
|
|
120
|
+
function durationBucket(durationMs) {
|
|
121
|
+
if (durationMs < 1_000)
|
|
122
|
+
return "under_1s";
|
|
123
|
+
if (durationMs < 5_000)
|
|
124
|
+
return "1s_to_5s";
|
|
125
|
+
if (durationMs < 30_000)
|
|
126
|
+
return "5s_to_30s";
|
|
127
|
+
if (durationMs < 120_000)
|
|
128
|
+
return "30s_to_2m";
|
|
129
|
+
return "over_2m";
|
|
130
|
+
}
|
|
131
|
+
export function createSetupAttempt(input) {
|
|
132
|
+
const startedAt = Date.now();
|
|
133
|
+
const diagnosticId = randomUUID();
|
|
134
|
+
const base = { provider: input.provider, method: input.method, diagnosticId };
|
|
135
|
+
const withDuration = () => ({
|
|
136
|
+
...base,
|
|
137
|
+
durationBucket: durationBucket(Math.max(0, Date.now() - startedAt)),
|
|
138
|
+
});
|
|
139
|
+
recordSetupStage({ ...withDuration(), stage: "attempt_start" });
|
|
140
|
+
let terminal = false;
|
|
141
|
+
const classify = (error, fallbackStage) => error instanceof SetupDiagnosticError
|
|
142
|
+
? error.classification
|
|
143
|
+
: { stage: fallbackStage, reason: "other", expected: false };
|
|
144
|
+
const failureInput = (classification) => ({
|
|
145
|
+
...withDuration(),
|
|
146
|
+
stage: classification.stage,
|
|
147
|
+
reason: classification.reason,
|
|
148
|
+
...(classification.httpStatusCode === undefined
|
|
149
|
+
? {}
|
|
150
|
+
: { httpStatusCode: classification.httpStatusCode }),
|
|
151
|
+
});
|
|
152
|
+
const recordException = (error, classification) => {
|
|
153
|
+
if (classification.expected)
|
|
154
|
+
return;
|
|
155
|
+
const exceptionCause = error instanceof SetupDiagnosticError && error.cause !== undefined
|
|
156
|
+
? error.cause
|
|
157
|
+
: error;
|
|
158
|
+
recordUnexpectedException(exceptionCause, {
|
|
159
|
+
category: "setup",
|
|
160
|
+
provider: input.provider,
|
|
161
|
+
setupStage: classification.stage,
|
|
162
|
+
reason: classification.reason,
|
|
163
|
+
}, diagnosticId);
|
|
164
|
+
};
|
|
165
|
+
return {
|
|
166
|
+
...base,
|
|
167
|
+
stageCompleted(stage) {
|
|
168
|
+
if (terminal)
|
|
169
|
+
return;
|
|
170
|
+
recordSetupStage({ ...withDuration(), stage });
|
|
171
|
+
},
|
|
172
|
+
stageFailed(error, fallbackStage) {
|
|
173
|
+
const classification = classify(error, fallbackStage);
|
|
174
|
+
if (!terminal) {
|
|
175
|
+
recordSetupStageFailure(failureInput(classification));
|
|
176
|
+
recordException(error, classification);
|
|
177
|
+
}
|
|
178
|
+
return { diagnosticId, unexpected: !classification.expected };
|
|
179
|
+
},
|
|
180
|
+
failed(error, fallbackStage) {
|
|
181
|
+
const classification = classify(error, fallbackStage);
|
|
182
|
+
if (!terminal) {
|
|
183
|
+
terminal = true;
|
|
184
|
+
recordExpectedSetupFailure(failureInput(classification));
|
|
185
|
+
recordException(error, classification);
|
|
186
|
+
}
|
|
187
|
+
return { diagnosticId, unexpected: !classification.expected };
|
|
188
|
+
},
|
|
189
|
+
cancelled() {
|
|
190
|
+
if (terminal)
|
|
191
|
+
return;
|
|
192
|
+
terminal = true;
|
|
193
|
+
recordSetupResult({ ...withDuration(), result: "cancelled" });
|
|
194
|
+
},
|
|
195
|
+
succeeded() {
|
|
196
|
+
if (terminal)
|
|
197
|
+
return;
|
|
198
|
+
terminal = true;
|
|
199
|
+
recordSetupResult({ ...withDuration(), result: "succeeded" });
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
export const SETUP_TELEMETRY_FLUSH_DEADLINE_MS = 1_500;
|
|
204
|
+
/** Never let a bounded telemetry flush change or delay a command result. */
|
|
205
|
+
export async function withSetupTelemetryFlush(operation) {
|
|
206
|
+
try {
|
|
207
|
+
return await operation();
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await new Promise(resolve => {
|
|
211
|
+
let settled = false;
|
|
212
|
+
const finish = () => {
|
|
213
|
+
if (settled)
|
|
214
|
+
return;
|
|
215
|
+
settled = true;
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
resolve();
|
|
218
|
+
};
|
|
219
|
+
const timer = setTimeout(finish, SETUP_TELEMETRY_FLUSH_DEADLINE_MS);
|
|
220
|
+
void Promise.resolve()
|
|
221
|
+
.then(() => flushTelemetryWithin(SETUP_TELEMETRY_FLUSH_DEADLINE_MS))
|
|
222
|
+
.then(finish, finish);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
export function isPromptCancellation(error) {
|
|
227
|
+
return ownStringProperty(error, "name") === "ExitPromptError";
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Close an attempt that ended in a thrown error: a cancelled prompt is a user
|
|
231
|
+
* decision (no outcome returned); anything else is a failure at the given stage.
|
|
232
|
+
*/
|
|
233
|
+
export function failAttemptFromError(attempt, error, fallbackStage) {
|
|
234
|
+
if (isPromptCancellation(error)) {
|
|
235
|
+
attempt.cancelled();
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
return attempt.failed(error, fallbackStage);
|
|
239
|
+
}
|
|
@@ -3,28 +3,74 @@ import { promisify } from "util";
|
|
|
3
3
|
import { readFileSync, existsSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import os from "os";
|
|
6
|
+
import { SetupDiagnosticError } from "../telemetry/setup-diagnostics.js";
|
|
6
7
|
const execFileAsync = promisify(execFile);
|
|
8
|
+
function ownErrorCode(error) {
|
|
9
|
+
if ((typeof error !== "object" && typeof error !== "function") || error === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
const descriptor = Object.getOwnPropertyDescriptor(error, "code");
|
|
12
|
+
return descriptor && "value" in descriptor
|
|
13
|
+
&& (typeof descriptor.value === "string" || typeof descriptor.value === "number")
|
|
14
|
+
? descriptor.value
|
|
15
|
+
: undefined;
|
|
16
|
+
}
|
|
17
|
+
function credentialReadError(error, source) {
|
|
18
|
+
const code = ownErrorCode(error);
|
|
19
|
+
// `security find-generic-password` exits 44 when the item does not exist.
|
|
20
|
+
const reason = code === "EACCES" || code === "EPERM"
|
|
21
|
+
? "permission_denied"
|
|
22
|
+
: code === "ENOENT" || code === 44
|
|
23
|
+
? "not_found"
|
|
24
|
+
: "other";
|
|
25
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
26
|
+
return new SetupDiagnosticError(`${source} read failed: ${detail}`, {
|
|
27
|
+
stage: "credential_read",
|
|
28
|
+
reason,
|
|
29
|
+
expected: reason !== "other",
|
|
30
|
+
}, { cause: error });
|
|
31
|
+
}
|
|
32
|
+
function credentialParseError(error) {
|
|
33
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
34
|
+
return new SetupDiagnosticError(`Credential parse failed: ${detail}`, {
|
|
35
|
+
stage: "credential_parse",
|
|
36
|
+
reason: "malformed_credentials",
|
|
37
|
+
expected: true,
|
|
38
|
+
}, { cause: error });
|
|
39
|
+
}
|
|
7
40
|
/**
|
|
8
41
|
* macOS: extract OAuth tokens from the macOS Keychain.
|
|
9
42
|
* Uses execFile (not exec/execSync) — args are passed as an array,
|
|
10
43
|
* preventing any shell injection.
|
|
11
44
|
*/
|
|
12
45
|
export async function extractFromKeychain() {
|
|
46
|
+
const result = await extractFromKeychainDetailed();
|
|
47
|
+
return result.ok ? result.tokens : null;
|
|
48
|
+
}
|
|
49
|
+
export async function extractFromKeychainDetailed() {
|
|
50
|
+
let stdout;
|
|
13
51
|
try {
|
|
14
|
-
|
|
52
|
+
({ stdout } = await execFileAsync("security", [
|
|
15
53
|
"find-generic-password",
|
|
16
54
|
"-s", "Claude Code-credentials",
|
|
17
55
|
"-w",
|
|
18
|
-
]);
|
|
56
|
+
]));
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
return { ok: false, error: credentialReadError(error, "Keychain") };
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
19
62
|
const raw = JSON.parse(stdout.trim());
|
|
20
63
|
// Keychain JSON can be either:
|
|
21
64
|
// { claudeAiOauth: { accessToken, refreshToken, ... }, mcpOAuth: {...} }
|
|
22
65
|
// { accessToken, refreshToken, ... } (direct, older versions)
|
|
23
66
|
const oauth = raw.claudeAiOauth ?? raw;
|
|
24
|
-
|
|
67
|
+
const tokens = parseCredentialJson(oauth);
|
|
68
|
+
if (!tokens)
|
|
69
|
+
throw new TypeError("Credential object is missing required OAuth token fields");
|
|
70
|
+
return { ok: true, tokens };
|
|
25
71
|
}
|
|
26
|
-
catch {
|
|
27
|
-
return
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { ok: false, error: credentialParseError(error) };
|
|
28
74
|
}
|
|
29
75
|
}
|
|
30
76
|
/**
|
|
@@ -33,19 +79,41 @@ export async function extractFromKeychain() {
|
|
|
33
79
|
* No shell — pure Node.js file read.
|
|
34
80
|
*/
|
|
35
81
|
export function extractFromCredentialsFile() {
|
|
82
|
+
const result = extractFromCredentialsFileDetailed();
|
|
83
|
+
return result.ok ? result.tokens : null;
|
|
84
|
+
}
|
|
85
|
+
export function extractFromCredentialsFileDetailed() {
|
|
36
86
|
const credPath = join(os.homedir(), ".claude", ".credentials.json");
|
|
37
|
-
if (!existsSync(credPath))
|
|
38
|
-
return
|
|
87
|
+
if (!existsSync(credPath)) {
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: new SetupDiagnosticError("Claude credentials file was not found", {
|
|
91
|
+
stage: "credential_read",
|
|
92
|
+
reason: "not_found",
|
|
93
|
+
expected: true,
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
let contents;
|
|
98
|
+
try {
|
|
99
|
+
contents = readFileSync(credPath, "utf-8");
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return { ok: false, error: credentialReadError(error, "credentials file") };
|
|
103
|
+
}
|
|
39
104
|
try {
|
|
40
|
-
const raw = JSON.parse(
|
|
105
|
+
const raw = JSON.parse(contents);
|
|
41
106
|
// The file can have two shapes:
|
|
42
107
|
// { claudeAiOauth: { accessToken, refreshToken, expiresAt, scopes } }
|
|
43
108
|
// { accessToken, refreshToken, expiresAt, scopes } (direct)
|
|
44
109
|
const oauth = raw.claudeAiOauth ?? raw;
|
|
45
|
-
|
|
110
|
+
const tokens = parseCredentialJson(oauth);
|
|
111
|
+
if (!tokens)
|
|
112
|
+
throw new TypeError("Credential object is missing required OAuth token fields");
|
|
113
|
+
return { ok: true, tokens };
|
|
46
114
|
}
|
|
47
|
-
catch {
|
|
48
|
-
return
|
|
115
|
+
catch (error) {
|
|
116
|
+
return { ok: false, error: credentialParseError(error) };
|
|
49
117
|
}
|
|
50
118
|
}
|
|
51
119
|
/** Parse and normalise either a raw JSON string or an already-parsed object. */
|