@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,289 @@
|
|
|
1
|
+
import { SpanKind, SpanStatusCode, TraceFlags } from "@opentelemetry/api";
|
|
2
|
+
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
3
|
+
import { ExporterMetrics, OTLPExporterBase, createOtlpNetworkExportDelegate, } from "@opentelemetry/otlp-exporter-base";
|
|
4
|
+
import { JsonLogsSerializer, LogsExporterMetricsHelper, ProtobufTraceSerializer, TraceExporterMetricsHelper, } from "@opentelemetry/otlp-transformer";
|
|
5
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
6
|
+
import { POSTHOG_HOST, POSTHOG_PROJECT_TOKEN, POSTHOG_REQUEST_TIMEOUT_MS, } from "./contracts.js";
|
|
7
|
+
import { fromOtelAttributes, LOG_ATTRIBUTE_KEYS, RESOURCE_ATTRIBUTE_KEYS, reconstructLog, reconstructResource, reconstructSpan, SPAN_ATTRIBUTE_KEYS, toOtelAttributes, } from "./privacy.js";
|
|
8
|
+
const SUCCESS = { code: 0 };
|
|
9
|
+
const FAILED = { code: 1 };
|
|
10
|
+
const EXPORT_TIMEOUT_MS = POSTHOG_REQUEST_TIMEOUT_MS;
|
|
11
|
+
const LIFECYCLE_TIMEOUT_MS = 2_000;
|
|
12
|
+
const MAX_BATCH_SIZE = 100;
|
|
13
|
+
const POSTHOG_TRACE_URL = `${POSTHOG_HOST}/i/v1/traces`;
|
|
14
|
+
const POSTHOG_LOG_URL = `${POSTHOG_HOST}/i/v1/logs`;
|
|
15
|
+
function own(input, key) {
|
|
16
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
17
|
+
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
18
|
+
}
|
|
19
|
+
async function settleWithin(operation, timeoutMs) {
|
|
20
|
+
let timer;
|
|
21
|
+
try {
|
|
22
|
+
await Promise.race([
|
|
23
|
+
Promise.resolve().then(operation).catch(() => undefined),
|
|
24
|
+
new Promise(resolve => {
|
|
25
|
+
timer = setTimeout(resolve, timeoutMs);
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// Telemetry lifecycle failures are isolated from application behavior.
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function hrTimeToMilliseconds(time) {
|
|
38
|
+
return time[0] * 1_000 + time[1] / 1_000_000;
|
|
39
|
+
}
|
|
40
|
+
function millisecondsToHrTime(milliseconds) {
|
|
41
|
+
const seconds = Math.floor(milliseconds / 1_000);
|
|
42
|
+
return [seconds, Math.round((milliseconds - seconds * 1_000) * 1_000_000)];
|
|
43
|
+
}
|
|
44
|
+
function spanKindName(kind) {
|
|
45
|
+
switch (kind) {
|
|
46
|
+
case SpanKind.INTERNAL: return "internal";
|
|
47
|
+
case SpanKind.SERVER: return "server";
|
|
48
|
+
case SpanKind.CLIENT: return "client";
|
|
49
|
+
default: return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function otelSpanKind(kind) {
|
|
53
|
+
switch (kind) {
|
|
54
|
+
case "internal": return SpanKind.INTERNAL;
|
|
55
|
+
case "server": return SpanKind.SERVER;
|
|
56
|
+
case "client": return SpanKind.CLIENT;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function spanStatusName(code) {
|
|
60
|
+
switch (code) {
|
|
61
|
+
case SpanStatusCode.UNSET: return "unset";
|
|
62
|
+
case SpanStatusCode.OK: return "ok";
|
|
63
|
+
case SpanStatusCode.ERROR: return "error";
|
|
64
|
+
default: return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function otelSpanStatus(code) {
|
|
68
|
+
switch (code) {
|
|
69
|
+
case "unset": return SpanStatusCode.UNSET;
|
|
70
|
+
case "ok": return SpanStatusCode.OK;
|
|
71
|
+
case "error": return SpanStatusCode.ERROR;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function severityName(value) {
|
|
75
|
+
switch (value) {
|
|
76
|
+
case SeverityNumber.INFO: return "info";
|
|
77
|
+
case SeverityNumber.WARN: return "warn";
|
|
78
|
+
case SeverityNumber.ERROR: return "error";
|
|
79
|
+
case SeverityNumber.FATAL: return "fatal";
|
|
80
|
+
default: return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function otelSeverity(value) {
|
|
84
|
+
switch (value) {
|
|
85
|
+
case "info": return { text: "INFO", number: SeverityNumber.INFO };
|
|
86
|
+
case "warn": return { text: "WARN", number: SeverityNumber.WARN };
|
|
87
|
+
case "error": return { text: "ERROR", number: SeverityNumber.ERROR };
|
|
88
|
+
case "fatal": return { text: "FATAL", number: SeverityNumber.FATAL };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function safeResource(candidate, installationId) {
|
|
92
|
+
return reconstructResource(fromOtelAttributes(RESOURCE_ATTRIBUTE_KEYS, candidate.attributes), { installationId });
|
|
93
|
+
}
|
|
94
|
+
function rebuildSpan(candidate, snapshot) {
|
|
95
|
+
try {
|
|
96
|
+
const context = candidate.spanContext();
|
|
97
|
+
const resource = safeResource(candidate.resource, snapshot.state.installId);
|
|
98
|
+
const safe = reconstructSpan({
|
|
99
|
+
scope: candidate.instrumentationScope.name,
|
|
100
|
+
operation: own(candidate.attributes, "cc_router.operation"),
|
|
101
|
+
traceId: context.traceId,
|
|
102
|
+
spanId: context.spanId,
|
|
103
|
+
parentSpanId: candidate.parentSpanContext?.spanId,
|
|
104
|
+
kind: spanKindName(candidate.kind),
|
|
105
|
+
startTimeMs: hrTimeToMilliseconds(candidate.startTime),
|
|
106
|
+
durationMs: hrTimeToMilliseconds(candidate.duration),
|
|
107
|
+
statusCode: spanStatusName(candidate.status.code),
|
|
108
|
+
attributes: fromOtelAttributes(SPAN_ATTRIBUTE_KEYS, candidate.attributes),
|
|
109
|
+
});
|
|
110
|
+
if (!resource || !safe)
|
|
111
|
+
return undefined;
|
|
112
|
+
return newReadableSpan(safe, resource);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function newReadableSpan(safe, resource) {
|
|
119
|
+
const spanContext = { traceId: safe.traceId, spanId: safe.spanId, traceFlags: TraceFlags.SAMPLED };
|
|
120
|
+
return {
|
|
121
|
+
name: safe.name,
|
|
122
|
+
kind: otelSpanKind(safe.kind),
|
|
123
|
+
spanContext: () => ({ ...spanContext }),
|
|
124
|
+
...(safe.parentSpanId === undefined ? {} : {
|
|
125
|
+
parentSpanContext: {
|
|
126
|
+
traceId: safe.traceId,
|
|
127
|
+
spanId: safe.parentSpanId,
|
|
128
|
+
traceFlags: TraceFlags.SAMPLED,
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
startTime: millisecondsToHrTime(safe.startTimeMs),
|
|
132
|
+
endTime: millisecondsToHrTime(safe.startTimeMs + safe.durationMs),
|
|
133
|
+
duration: millisecondsToHrTime(safe.durationMs),
|
|
134
|
+
status: { code: otelSpanStatus(safe.statusCode) },
|
|
135
|
+
attributes: toOtelAttributes(SPAN_ATTRIBUTE_KEYS, safe.attributes),
|
|
136
|
+
links: [],
|
|
137
|
+
events: [],
|
|
138
|
+
ended: true,
|
|
139
|
+
resource: resourceFromAttributes({ ...resource }),
|
|
140
|
+
instrumentationScope: { name: safe.scope },
|
|
141
|
+
droppedAttributesCount: 0,
|
|
142
|
+
droppedEventsCount: 0,
|
|
143
|
+
droppedLinksCount: 0,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function rebuildLog(candidate, snapshot) {
|
|
147
|
+
try {
|
|
148
|
+
const resource = safeResource(candidate.resource, snapshot.state.installId);
|
|
149
|
+
// The diagnostic id is a per-attempt random UUID; reconstructLog still
|
|
150
|
+
// validates it and rejects anything equal to the install identity.
|
|
151
|
+
const diagnosticId = own(candidate.attributes, LOG_ATTRIBUTE_KEYS.diagnosticId);
|
|
152
|
+
const safe = reconstructLog({
|
|
153
|
+
scope: candidate.instrumentationScope.name,
|
|
154
|
+
body: candidate.body,
|
|
155
|
+
severity: severityName(candidate.severityNumber),
|
|
156
|
+
timestampMs: hrTimeToMilliseconds(candidate.hrTime),
|
|
157
|
+
traceId: candidate.spanContext?.traceId,
|
|
158
|
+
spanId: candidate.spanContext?.spanId,
|
|
159
|
+
attributes: fromOtelAttributes(LOG_ATTRIBUTE_KEYS, candidate.attributes),
|
|
160
|
+
}, {
|
|
161
|
+
installationId: snapshot.state.installId,
|
|
162
|
+
...(typeof diagnosticId === "string" ? { diagnosticId } : {}),
|
|
163
|
+
});
|
|
164
|
+
if (!resource || !safe)
|
|
165
|
+
return undefined;
|
|
166
|
+
return newReadableLog(safe, resource);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function newReadableLog(safe, resource) {
|
|
173
|
+
const severity = otelSeverity(safe.severity);
|
|
174
|
+
const hrTime = millisecondsToHrTime(safe.timestampMs);
|
|
175
|
+
return {
|
|
176
|
+
hrTime,
|
|
177
|
+
hrTimeObserved: [...hrTime],
|
|
178
|
+
...(safe.traceId && safe.spanId
|
|
179
|
+
? { spanContext: { traceId: safe.traceId, spanId: safe.spanId, traceFlags: TraceFlags.SAMPLED } }
|
|
180
|
+
: {}),
|
|
181
|
+
severityText: severity.text,
|
|
182
|
+
severityNumber: severity.number,
|
|
183
|
+
body: safe.body,
|
|
184
|
+
resource: resourceFromAttributes({ ...resource }),
|
|
185
|
+
instrumentationScope: { name: safe.scope },
|
|
186
|
+
attributes: toOtelAttributes(LOG_ATTRIBUTE_KEYS, safe.attributes),
|
|
187
|
+
droppedAttributesCount: 0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Wrap an OTLP delegate so nothing reaches the network unless consent still
|
|
192
|
+
* holds, and only records rebuilt from the closed allowlist are handed over.
|
|
193
|
+
*/
|
|
194
|
+
function gatedExporter(getSnapshot, rebuild, delegate) {
|
|
195
|
+
return {
|
|
196
|
+
export(records, callback) {
|
|
197
|
+
let settled = false;
|
|
198
|
+
let timer;
|
|
199
|
+
const settle = (result) => {
|
|
200
|
+
if (settled)
|
|
201
|
+
return;
|
|
202
|
+
settled = true;
|
|
203
|
+
clearTimeout(timer);
|
|
204
|
+
callback(result);
|
|
205
|
+
};
|
|
206
|
+
try {
|
|
207
|
+
const snapshot = getSnapshot();
|
|
208
|
+
if (!snapshot) {
|
|
209
|
+
settle(SUCCESS);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
const safe = records.slice(0, MAX_BATCH_SIZE).flatMap(record => {
|
|
213
|
+
const rebuilt = rebuild(record, snapshot);
|
|
214
|
+
return rebuilt ? [rebuilt] : [];
|
|
215
|
+
});
|
|
216
|
+
// Re-check: an opt-out during reconstruction must still drop the batch.
|
|
217
|
+
if (safe.length === 0 || !getSnapshot()) {
|
|
218
|
+
settle(SUCCESS);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
timer = setTimeout(() => settle(FAILED), EXPORT_TIMEOUT_MS);
|
|
222
|
+
timer.unref?.();
|
|
223
|
+
delegate.export(safe, result => settle(result.code === 0 ? SUCCESS : FAILED));
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
settle(FAILED);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
async forceFlush() {
|
|
230
|
+
if (!getSnapshot())
|
|
231
|
+
return;
|
|
232
|
+
await settleWithin(() => delegate.forceFlush?.(), LIFECYCLE_TIMEOUT_MS);
|
|
233
|
+
},
|
|
234
|
+
async shutdown() {
|
|
235
|
+
await settleWithin(() => delegate.shutdown(), LIFECYCLE_TIMEOUT_MS);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* One HTTP POST per export, consent re-read immediately before it. The SDK's
|
|
241
|
+
* stock exporters wrap their transport in a retrying layer that would resend a
|
|
242
|
+
* batch after a 503 without consulting consent again; this transport never
|
|
243
|
+
* reports a retryable result, so nothing is transmitted twice.
|
|
244
|
+
*/
|
|
245
|
+
function consentGatedTransport(url, contentType, getSnapshot) {
|
|
246
|
+
return {
|
|
247
|
+
async send(data, timeoutMillis) {
|
|
248
|
+
if (!getSnapshot()?.enabled)
|
|
249
|
+
return { status: "success" };
|
|
250
|
+
try {
|
|
251
|
+
const response = await fetch(url, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: {
|
|
254
|
+
"content-type": contentType,
|
|
255
|
+
authorization: `Bearer ${POSTHOG_PROJECT_TOKEN}`,
|
|
256
|
+
},
|
|
257
|
+
body: new Uint8Array(data),
|
|
258
|
+
signal: AbortSignal.timeout(Math.max(1, timeoutMillis)),
|
|
259
|
+
});
|
|
260
|
+
if (response.ok)
|
|
261
|
+
return { status: "success" };
|
|
262
|
+
return { status: "failure", error: new Error(`OTLP export rejected with HTTP ${response.status}`) };
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// The raw transport error may name hosts or addresses; it stays local.
|
|
266
|
+
return { status: "failure", error: new Error("OTLP export failed") };
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
shutdown() { },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function otlpExporter(url, contentType, serializer, metricsHelper, getSnapshot) {
|
|
273
|
+
const options = { timeoutMillis: POSTHOG_REQUEST_TIMEOUT_MS, concurrencyLimit: 1, compression: "none" };
|
|
274
|
+
return new OTLPExporterBase(createOtlpNetworkExportDelegate(options, serializer, new ExporterMetrics({
|
|
275
|
+
componentType: "cc_router_otlp_exporter",
|
|
276
|
+
metricsHelper,
|
|
277
|
+
url,
|
|
278
|
+
meterProvider: undefined,
|
|
279
|
+
responseAttributesFromError: () => ({}),
|
|
280
|
+
}), consentGatedTransport(url, contentType, getSnapshot)));
|
|
281
|
+
}
|
|
282
|
+
export function createPostHogOtlpExporters(options) {
|
|
283
|
+
const traceUrl = options.traceUrl ?? POSTHOG_TRACE_URL;
|
|
284
|
+
const logUrl = options.logUrl ?? POSTHOG_LOG_URL;
|
|
285
|
+
return {
|
|
286
|
+
spanExporter: gatedExporter(options.getSnapshot, rebuildSpan, otlpExporter(traceUrl, "application/x-protobuf", ProtobufTraceSerializer, TraceExporterMetricsHelper, options.getSnapshot)),
|
|
287
|
+
logExporter: gatedExporter(options.getSnapshot, rebuildLog, otlpExporter(logUrl, "application/json", JsonLogsSerializer, LogsExporterMetricsHelper, options.getSnapshot)),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { PostHog } from "posthog-node";
|
|
2
|
+
import { ERROR_KINDS, MAX_STACK_FRAMES, MAX_STACK_FRAME_PATH_LENGTH, OPERATIONS, POSTHOG_FLUSH_AT, POSTHOG_FLUSH_INTERVAL_MS, POSTHOG_HOST, POSTHOG_MAX_QUEUE_SIZE, POSTHOG_PROJECT_TOKEN, POSTHOG_REQUEST_TIMEOUT_MS, PROVIDERS, RUNTIME_MODES, SETUP_REASONS, SETUP_STAGES, SYSTEM_ERROR_CODES, } from "./contracts.js";
|
|
3
|
+
import { reconstructAnalyticsEvent } from "./privacy.js";
|
|
4
|
+
/** Stamped on every capture and re-checked in before_send against live consent. */
|
|
5
|
+
const CONSENT_GENERATION_PROPERTY = "cc_router.consent_generation";
|
|
6
|
+
const QUEUE_KEYS = ["queue", "ai_queue", "logs_queue"];
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function own(input, key) {
|
|
11
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
12
|
+
return descriptor && "value" in descriptor ? descriptor.value : undefined;
|
|
13
|
+
}
|
|
14
|
+
function member(values, value) {
|
|
15
|
+
return typeof value === "string" && values.includes(value)
|
|
16
|
+
? value
|
|
17
|
+
: undefined;
|
|
18
|
+
}
|
|
19
|
+
function uuid(value) {
|
|
20
|
+
if (typeof value !== "string")
|
|
21
|
+
return undefined;
|
|
22
|
+
const normalized = value.toLowerCase();
|
|
23
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)
|
|
24
|
+
? normalized
|
|
25
|
+
: undefined;
|
|
26
|
+
}
|
|
27
|
+
function positiveInteger(value) {
|
|
28
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 1 ? value : undefined;
|
|
29
|
+
}
|
|
30
|
+
function httpStatusCode(value) {
|
|
31
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 100 && value <= 599
|
|
32
|
+
? value
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
function optionalMember(values, value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return { valid: true };
|
|
38
|
+
const normalized = member(values, value);
|
|
39
|
+
return normalized === undefined ? { valid: false } : { valid: true, value: normalized };
|
|
40
|
+
}
|
|
41
|
+
function safeFramePath(value) {
|
|
42
|
+
if (typeof value !== "string" || value.length === 0 || value.length > MAX_STACK_FRAME_PATH_LENGTH) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
if (!value.startsWith("dist/") && !value.startsWith("node_modules/"))
|
|
46
|
+
return undefined;
|
|
47
|
+
const segments = value.split("/");
|
|
48
|
+
if (segments.some(segment => segment.length === 0
|
|
49
|
+
|| segment === "."
|
|
50
|
+
|| segment === ".."
|
|
51
|
+
|| /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segment)
|
|
52
|
+
|| !/^[0-9A-Za-z@._+~-]+$/.test(segment))) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
if (segments[0] === "node_modules") {
|
|
56
|
+
const packageSegments = segments[1]?.startsWith("@") ? 2 : 1;
|
|
57
|
+
if (segments.length < packageSegments + 2)
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
function reconstructExceptionFrames(input) {
|
|
63
|
+
if (!Array.isArray(input) || input.length > MAX_STACK_FRAMES)
|
|
64
|
+
return undefined;
|
|
65
|
+
const frames = [];
|
|
66
|
+
for (const candidate of input) {
|
|
67
|
+
if (!isRecord(candidate))
|
|
68
|
+
return undefined;
|
|
69
|
+
const filename = safeFramePath(own(candidate, "filename"));
|
|
70
|
+
const line = positiveInteger(own(candidate, "lineno"));
|
|
71
|
+
const column = positiveInteger(own(candidate, "colno"));
|
|
72
|
+
if (!filename || !line || !column)
|
|
73
|
+
return undefined;
|
|
74
|
+
frames.push({ platform: "node:javascript", filename, lineno: line, colno: column });
|
|
75
|
+
}
|
|
76
|
+
return frames;
|
|
77
|
+
}
|
|
78
|
+
function reconstructExceptionList(input, reason) {
|
|
79
|
+
if (!Array.isArray(input) || input.length !== 1 || !isRecord(input[0]))
|
|
80
|
+
return undefined;
|
|
81
|
+
const exception = input[0];
|
|
82
|
+
const mechanism = own(exception, "mechanism");
|
|
83
|
+
if (own(exception, "type") !== "Error"
|
|
84
|
+
|| own(exception, "value") !== reason
|
|
85
|
+
|| !isRecord(mechanism)
|
|
86
|
+
|| own(mechanism, "type") !== "generic"
|
|
87
|
+
|| own(mechanism, "handled") !== true
|
|
88
|
+
|| own(mechanism, "synthetic") !== false) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const stacktrace = own(exception, "stacktrace");
|
|
92
|
+
if (stacktrace === undefined) {
|
|
93
|
+
return [{ type: "Error", value: reason, mechanism: { type: "generic", handled: true, synthetic: false } }];
|
|
94
|
+
}
|
|
95
|
+
if (!isRecord(stacktrace) || own(stacktrace, "type") !== "raw")
|
|
96
|
+
return undefined;
|
|
97
|
+
const frames = reconstructExceptionFrames(own(stacktrace, "frames"));
|
|
98
|
+
if (!frames)
|
|
99
|
+
return undefined;
|
|
100
|
+
return [{
|
|
101
|
+
type: "Error",
|
|
102
|
+
value: reason,
|
|
103
|
+
mechanism: { type: "generic", handled: true, synthetic: false },
|
|
104
|
+
stacktrace: { type: "raw", frames },
|
|
105
|
+
}];
|
|
106
|
+
}
|
|
107
|
+
/** Rebuild the SDK's own $exception payload from the closed allowlist. */
|
|
108
|
+
function reconstructExceptionEvent(event, installationId) {
|
|
109
|
+
if (event.event !== "$exception" || !isRecord(event.properties))
|
|
110
|
+
return null;
|
|
111
|
+
const properties = event.properties;
|
|
112
|
+
const category = own(properties, "category");
|
|
113
|
+
const reason = member(SETUP_REASONS, own(properties, "reason"));
|
|
114
|
+
const errorKind = member(ERROR_KINDS, own(properties, "errorKind"));
|
|
115
|
+
const fingerprint = own(properties, "$exception_fingerprint");
|
|
116
|
+
const diagnosticId = uuid(own(properties, "diagnosticId"));
|
|
117
|
+
if ((category !== "setup" && category !== "runtime")
|
|
118
|
+
|| !reason
|
|
119
|
+
|| !errorKind
|
|
120
|
+
|| typeof fingerprint !== "string"
|
|
121
|
+
|| !/^[0-9a-f]{64}$/.test(fingerprint)
|
|
122
|
+
|| !diagnosticId
|
|
123
|
+
|| diagnosticId === installationId
|
|
124
|
+
|| own(properties, "$exception_level") !== "error") {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
const exceptionList = reconstructExceptionList(own(properties, "$exception_list"), reason);
|
|
128
|
+
if (!exceptionList)
|
|
129
|
+
return null;
|
|
130
|
+
const systemErrorCode = optionalMember(SYSTEM_ERROR_CODES, own(properties, "systemErrorCode"));
|
|
131
|
+
const operation = optionalMember(OPERATIONS, own(properties, "operation"));
|
|
132
|
+
const provider = optionalMember(PROVIDERS, own(properties, "provider"));
|
|
133
|
+
const setupStage = optionalMember(SETUP_STAGES, own(properties, "setupStage"));
|
|
134
|
+
const runtimeMode = optionalMember(RUNTIME_MODES, own(properties, "runtimeMode"));
|
|
135
|
+
const statusCandidate = own(properties, "httpStatusCode");
|
|
136
|
+
const status = statusCandidate === undefined ? undefined : httpStatusCode(statusCandidate);
|
|
137
|
+
if (!systemErrorCode.valid || !operation.valid || !provider.valid || !setupStage.valid || !runtimeMode.valid
|
|
138
|
+
|| (statusCandidate !== undefined && status === undefined)) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const safeProperties = {
|
|
142
|
+
$exception_list: exceptionList,
|
|
143
|
+
$exception_level: "error",
|
|
144
|
+
$exception_fingerprint: fingerprint,
|
|
145
|
+
category,
|
|
146
|
+
reason,
|
|
147
|
+
errorKind,
|
|
148
|
+
};
|
|
149
|
+
if (systemErrorCode.value !== undefined)
|
|
150
|
+
safeProperties.systemErrorCode = systemErrorCode.value;
|
|
151
|
+
if (status !== undefined)
|
|
152
|
+
safeProperties.httpStatusCode = status;
|
|
153
|
+
if (operation.value !== undefined)
|
|
154
|
+
safeProperties.operation = operation.value;
|
|
155
|
+
if (provider.value !== undefined)
|
|
156
|
+
safeProperties.provider = provider.value;
|
|
157
|
+
if (setupStage.value !== undefined)
|
|
158
|
+
safeProperties.setupStage = setupStage.value;
|
|
159
|
+
if (runtimeMode.value !== undefined)
|
|
160
|
+
safeProperties.runtimeMode = runtimeMode.value;
|
|
161
|
+
safeProperties.diagnosticId = diagnosticId;
|
|
162
|
+
safeProperties.$process_person_profile = false;
|
|
163
|
+
safeProperties.$geoip_disable = true;
|
|
164
|
+
return { event: "$exception", distinctId: installationId, disableGeoip: true, properties: safeProperties };
|
|
165
|
+
}
|
|
166
|
+
function noOpResponse() {
|
|
167
|
+
return { status: 204, text: async () => "", json: async () => ({}), headers: { get: () => null } };
|
|
168
|
+
}
|
|
169
|
+
function boundedDeadline(value) {
|
|
170
|
+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
171
|
+
}
|
|
172
|
+
async function settleWithin(operation, deadlineMs) {
|
|
173
|
+
let timeout;
|
|
174
|
+
try {
|
|
175
|
+
await Promise.race([
|
|
176
|
+
Promise.resolve().then(operation).catch(() => undefined),
|
|
177
|
+
new Promise(resolve => { timeout = setTimeout(resolve, boundedDeadline(deadlineMs)); }),
|
|
178
|
+
]);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Telemetry lifecycle failures must never affect application behavior.
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
clearTimeout(timeout);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function exceptionProperties(exception, consentGeneration) {
|
|
188
|
+
const properties = {
|
|
189
|
+
$exception_fingerprint: exception.fingerprint,
|
|
190
|
+
category: exception.category,
|
|
191
|
+
reason: exception.reason,
|
|
192
|
+
errorKind: exception.errorKind,
|
|
193
|
+
diagnosticId: exception.diagnosticId,
|
|
194
|
+
$process_person_profile: false,
|
|
195
|
+
$geoip_disable: true,
|
|
196
|
+
[CONSENT_GENERATION_PROPERTY]: consentGeneration,
|
|
197
|
+
};
|
|
198
|
+
if (exception.systemErrorCode !== undefined)
|
|
199
|
+
properties.systemErrorCode = exception.systemErrorCode;
|
|
200
|
+
if (exception.httpStatusCode !== undefined)
|
|
201
|
+
properties.httpStatusCode = exception.httpStatusCode;
|
|
202
|
+
if (exception.operation !== undefined)
|
|
203
|
+
properties.operation = exception.operation;
|
|
204
|
+
if (exception.provider !== undefined)
|
|
205
|
+
properties.provider = exception.provider;
|
|
206
|
+
if (exception.setupStage !== undefined)
|
|
207
|
+
properties.setupStage = exception.setupStage;
|
|
208
|
+
if (exception.runtimeMode !== undefined)
|
|
209
|
+
properties.runtimeMode = exception.runtimeMode;
|
|
210
|
+
return properties;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* posthog-node wrapper pinned to the EU host with no person profiles and no
|
|
214
|
+
* GeoIP. Consent is enforced three times: when a capture is prepared, in
|
|
215
|
+
* before_send (which also rebuilds every event from the closed allowlist), and
|
|
216
|
+
* in the transport, which answers 204 without a request once consent is gone.
|
|
217
|
+
*/
|
|
218
|
+
export function createPostHogTelemetryClient(options) {
|
|
219
|
+
const getSnapshot = options.getSnapshot;
|
|
220
|
+
const transport = options.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
221
|
+
const createSdkClient = options.createSdkClient
|
|
222
|
+
?? ((token, sdkOptions) => new PostHog(token, sdkOptions));
|
|
223
|
+
let sdkClient;
|
|
224
|
+
let initializationFailed = false;
|
|
225
|
+
let shutdownStarted = false;
|
|
226
|
+
const activeTransports = new Set();
|
|
227
|
+
const abortActiveTransports = () => {
|
|
228
|
+
for (const controller of activeTransports)
|
|
229
|
+
controller.abort();
|
|
230
|
+
activeTransports.clear();
|
|
231
|
+
};
|
|
232
|
+
const discardPending = () => {
|
|
233
|
+
abortActiveTransports();
|
|
234
|
+
for (const key of QUEUE_KEYS) {
|
|
235
|
+
try {
|
|
236
|
+
sdkClient?.setPersistedProperty(key, []);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Queue cleanup failures are still caught by the transport gate.
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const beforeSend = (event) => {
|
|
244
|
+
try {
|
|
245
|
+
if (!event || !isRecord(event.properties))
|
|
246
|
+
return null;
|
|
247
|
+
const snapshot = getSnapshot();
|
|
248
|
+
const installationId = snapshot && uuid(snapshot.state.installId);
|
|
249
|
+
if (!snapshot || !installationId)
|
|
250
|
+
return null;
|
|
251
|
+
if (own(event.properties, CONSENT_GENERATION_PROPERTY) !== snapshot.state.consentGeneration) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
if (event.event === "$exception")
|
|
255
|
+
return reconstructExceptionEvent(event, installationId);
|
|
256
|
+
const diagnosticId = own(event.properties, "diagnosticId");
|
|
257
|
+
const safe = reconstructAnalyticsEvent({
|
|
258
|
+
event: event.event,
|
|
259
|
+
properties: event.properties,
|
|
260
|
+
}, {
|
|
261
|
+
installationId,
|
|
262
|
+
...(typeof diagnosticId === "string" ? { diagnosticId } : {}),
|
|
263
|
+
});
|
|
264
|
+
if (!safe)
|
|
265
|
+
return null;
|
|
266
|
+
return {
|
|
267
|
+
event: safe.event,
|
|
268
|
+
distinctId: safe.installationId,
|
|
269
|
+
disableGeoip: true,
|
|
270
|
+
properties: { ...safe.properties, $process_person_profile: false, $geoip_disable: true },
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
const gatedFetch = async (url, fetchOptions) => {
|
|
278
|
+
try {
|
|
279
|
+
// Consent is re-read immediately before every transmission so a batch
|
|
280
|
+
// the SDK queued before an opt-out never leaves the process.
|
|
281
|
+
if (!getSnapshot()?.enabled)
|
|
282
|
+
return noOpResponse();
|
|
283
|
+
const controller = new AbortController();
|
|
284
|
+
const inherited = fetchOptions.signal;
|
|
285
|
+
const forwardAbort = () => { controller.abort(); };
|
|
286
|
+
if (inherited?.aborted)
|
|
287
|
+
controller.abort();
|
|
288
|
+
else
|
|
289
|
+
inherited?.addEventListener("abort", forwardAbort, { once: true });
|
|
290
|
+
activeTransports.add(controller);
|
|
291
|
+
try {
|
|
292
|
+
return await transport(url, { ...fetchOptions, signal: controller.signal });
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
inherited?.removeEventListener("abort", forwardAbort);
|
|
296
|
+
activeTransports.delete(controller);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
throw new Error("PostHog transport failed");
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
const activeClient = (capturedConsentGeneration) => {
|
|
304
|
+
if (shutdownStarted || initializationFailed)
|
|
305
|
+
return undefined;
|
|
306
|
+
const snapshot = getSnapshot();
|
|
307
|
+
const installationId = snapshot && uuid(snapshot.state.installId);
|
|
308
|
+
if (!snapshot || !installationId)
|
|
309
|
+
return undefined;
|
|
310
|
+
if (snapshot.state.consentGeneration !== capturedConsentGeneration)
|
|
311
|
+
return undefined;
|
|
312
|
+
if (!sdkClient) {
|
|
313
|
+
try {
|
|
314
|
+
sdkClient = createSdkClient(POSTHOG_PROJECT_TOKEN, {
|
|
315
|
+
host: POSTHOG_HOST,
|
|
316
|
+
disableGeoip: true,
|
|
317
|
+
enableExceptionAutocapture: false,
|
|
318
|
+
disableRemoteConfig: true,
|
|
319
|
+
disableRemoteFeatureFlags: true,
|
|
320
|
+
flushAt: POSTHOG_FLUSH_AT,
|
|
321
|
+
maxQueueSize: POSTHOG_MAX_QUEUE_SIZE,
|
|
322
|
+
flushInterval: POSTHOG_FLUSH_INTERVAL_MS,
|
|
323
|
+
requestTimeout: POSTHOG_REQUEST_TIMEOUT_MS,
|
|
324
|
+
fetchRetryCount: 0,
|
|
325
|
+
disableCompression: true,
|
|
326
|
+
before_send: beforeSend,
|
|
327
|
+
fetch: gatedFetch,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
initializationFailed = true;
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return { client: sdkClient, installationId };
|
|
336
|
+
};
|
|
337
|
+
return {
|
|
338
|
+
captureAnalytics(event, consentGeneration) {
|
|
339
|
+
try {
|
|
340
|
+
const active = activeClient(consentGeneration);
|
|
341
|
+
active?.client.capture({
|
|
342
|
+
event: event.event,
|
|
343
|
+
distinctId: active.installationId,
|
|
344
|
+
disableGeoip: true,
|
|
345
|
+
properties: {
|
|
346
|
+
...event.properties,
|
|
347
|
+
$process_person_profile: false,
|
|
348
|
+
$geoip_disable: true,
|
|
349
|
+
[CONSENT_GENERATION_PROPERTY]: consentGeneration,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
// Capture is best effort only.
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
captureException(exception, consentGeneration) {
|
|
358
|
+
try {
|
|
359
|
+
const active = activeClient(consentGeneration);
|
|
360
|
+
active?.client.captureException(exception.error, active.installationId, exceptionProperties(exception, consentGeneration));
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
// Capture is best effort only.
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
async captureExceptionImmediate(exception, consentGeneration) {
|
|
367
|
+
try {
|
|
368
|
+
const active = activeClient(consentGeneration);
|
|
369
|
+
if (!active)
|
|
370
|
+
return;
|
|
371
|
+
await active.client.captureExceptionImmediate(exception.error, active.installationId, exceptionProperties(exception, consentGeneration));
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
// A crash-path capture must not change process behavior.
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
async flushWithin(deadlineMs) {
|
|
378
|
+
const client = sdkClient;
|
|
379
|
+
if (!client)
|
|
380
|
+
return;
|
|
381
|
+
if (!getSnapshot()) {
|
|
382
|
+
discardPending();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await settleWithin(() => client.flush(), deadlineMs);
|
|
386
|
+
},
|
|
387
|
+
async shutdownWithin(deadlineMs) {
|
|
388
|
+
const client = sdkClient;
|
|
389
|
+
shutdownStarted = true;
|
|
390
|
+
if (!client)
|
|
391
|
+
return;
|
|
392
|
+
await settleWithin(() => client.shutdown(boundedDeadline(deadlineMs)), deadlineMs);
|
|
393
|
+
abortActiveTransports();
|
|
394
|
+
sdkClient = undefined;
|
|
395
|
+
},
|
|
396
|
+
discardPending,
|
|
397
|
+
};
|
|
398
|
+
}
|