@juspay/neurolink 12.14.5 → 12.14.7
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 +2 -2
- package/dist/browser/neurolink.min.js +412 -412
- package/dist/cli/commands/proxy.d.ts +1 -1
- package/dist/cli/commands/proxy.js +123 -14
- package/dist/cli/commands/proxyRestart.d.ts +3 -0
- package/dist/cli/commands/proxyRestart.js +88 -0
- package/dist/cli/parser.js +3 -1
- package/dist/proxy/bodyCaptureWorker.js +26 -1
- package/dist/proxy/codexUsage.js +83 -2
- package/dist/proxy/otelLogSink.d.ts +13 -0
- package/dist/proxy/otelLogSink.js +62 -1
- package/dist/proxy/proxyTraceContext.d.ts +21 -0
- package/dist/proxy/proxyTraceContext.js +47 -0
- package/dist/proxy/proxyTracer.d.ts +9 -5
- package/dist/proxy/proxyTracer.js +81 -2
- package/dist/proxy/requestLogger.js +24 -18
- package/dist/proxy/restartControl.d.ts +12 -0
- package/dist/proxy/restartControl.js +283 -0
- package/dist/proxy/rollingWorkerSupervisor.js +8 -0
- package/dist/server/routes/codexProxyRoutes.js +414 -332
- package/dist/types/cli.d.ts +7 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.js +1 -0
- package/dist/types/proxy.d.ts +119 -3
- package/dist/types/proxyRestart.d.ts +49 -0
- package/dist/types/proxyRestart.js +1 -0
- package/docs-site/static/search-index.json +1 -1
- package/package.json +2 -1
- package/scripts/observability/check-proxy-telemetry.mjs +29 -241
- package/scripts/observability/proxy-telemetry-backend.mjs +202 -0
- package/scripts/observability/proxy-telemetry-check.mjs +544 -0
- package/scripts/observability/query-proxy-history.mjs +30 -19
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/** Read-only coverage checks for stored OTLP telemetry, without local log scanning. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { queryProxyHistory } from "./query-proxy-history.mjs";
|
|
4
|
+
import {
|
|
5
|
+
resolveProxyTelemetryBackend,
|
|
6
|
+
queryProxyTelemetry,
|
|
7
|
+
validateProxyTelemetryBackend,
|
|
8
|
+
} from "./proxy-telemetry-backend.mjs";
|
|
9
|
+
|
|
10
|
+
/** Empty traffic, unavailable evidence, and partial queries cannot become a green check.
|
|
11
|
+
* @param {import("../../src/lib/types/index.js").ProxyTelemetryDoctorOptions} options
|
|
12
|
+
*/
|
|
13
|
+
export async function checkProxyTelemetry({
|
|
14
|
+
backend,
|
|
15
|
+
startTime,
|
|
16
|
+
endTime,
|
|
17
|
+
proxyUrl = "http://127.0.0.1:55669",
|
|
18
|
+
maxRows = 10000,
|
|
19
|
+
fetchImpl = fetch,
|
|
20
|
+
}) {
|
|
21
|
+
validateProxyTelemetryBackend(backend);
|
|
22
|
+
if (
|
|
23
|
+
!Number.isSafeInteger(startTime) ||
|
|
24
|
+
!Number.isSafeInteger(endTime) ||
|
|
25
|
+
startTime < 0 ||
|
|
26
|
+
startTime >= endTime ||
|
|
27
|
+
!Number.isSafeInteger(maxRows) ||
|
|
28
|
+
maxRows < 1 ||
|
|
29
|
+
maxRows > 100000
|
|
30
|
+
) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
"Provide an increasing microsecond time range and maxRows between 1 and 100000",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
const budget = { used: 0, limit: 512 };
|
|
36
|
+
/** @type {import("../../src/lib/types/index.js").ProxyTelemetryCheck[]} */
|
|
37
|
+
const checks = [];
|
|
38
|
+
/** @type {unknown[]} */
|
|
39
|
+
const queries = [];
|
|
40
|
+
/** @param {string} name @param {import("../../src/lib/types/index.js").ProxyTelemetryCheck["status"]} status @param {unknown} evidence */
|
|
41
|
+
const add = (name, status, evidence) =>
|
|
42
|
+
checks.push({ name, status, evidence });
|
|
43
|
+
/** @param {string} sql @param {string} signal @param {Partial<import("../../src/lib/types/index.js").ProxyTelemetryQueryOptions>} options */
|
|
44
|
+
const query = async (sql, signal = "logs", options = {}) => {
|
|
45
|
+
if (budget.used >= budget.limit) {
|
|
46
|
+
throw new Error("Telemetry verification query budget reached");
|
|
47
|
+
}
|
|
48
|
+
budget.used++;
|
|
49
|
+
const result = await queryProxyTelemetry(
|
|
50
|
+
backend,
|
|
51
|
+
{ sql, signal, startTime, endTime, ...options },
|
|
52
|
+
fetchImpl,
|
|
53
|
+
);
|
|
54
|
+
queries.push(result.query);
|
|
55
|
+
return result.rows;
|
|
56
|
+
};
|
|
57
|
+
const endpoint = new URL("/status", proxyUrl);
|
|
58
|
+
if (
|
|
59
|
+
endpoint.username ||
|
|
60
|
+
endpoint.password ||
|
|
61
|
+
(!(
|
|
62
|
+
["localhost", "127.0.0.1", "[::1]"].includes(endpoint.hostname) &&
|
|
63
|
+
endpoint.protocol === "http:"
|
|
64
|
+
) &&
|
|
65
|
+
endpoint.protocol !== "https:")
|
|
66
|
+
) {
|
|
67
|
+
throw new Error("Proxy diagnostics require HTTPS outside loopback");
|
|
68
|
+
}
|
|
69
|
+
const response = await fetchImpl(endpoint, {
|
|
70
|
+
redirect: "error",
|
|
71
|
+
signal: globalThis.AbortSignal.timeout(10000),
|
|
72
|
+
});
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`Proxy status failed: HTTP ${response.status}`);
|
|
75
|
+
}
|
|
76
|
+
const runtime = await response.json(),
|
|
77
|
+
logs = runtime.observability?.requestLogs;
|
|
78
|
+
add(
|
|
79
|
+
"runtime",
|
|
80
|
+
runtime.ready && runtime.acceptingConnections ? "pass" : "fail",
|
|
81
|
+
{ pid: runtime.pid, version: runtime.version, ready: runtime.ready },
|
|
82
|
+
);
|
|
83
|
+
add(
|
|
84
|
+
"otel_logging",
|
|
85
|
+
logs?.otel?.initialized && logs.diskEnabled === false ? "pass" : "fail",
|
|
86
|
+
{ initialized: logs?.otel?.initialized, diskEnabled: logs?.diskEnabled },
|
|
87
|
+
);
|
|
88
|
+
/** @type {Array<{kind: string, dropped: number, exportUnconfirmed: number, outstanding: number, capacity: number, recentFailures?: unknown, failureHistoryEvicted?: number}>} */
|
|
89
|
+
const failures = (logs?.otel?.queues ?? []).map(
|
|
90
|
+
(
|
|
91
|
+
/** @type {{kind: string, dropped: number, exportUnconfirmed: number, outstanding: number, capacity: number, recentFailures?: unknown, failureHistoryEvicted?: number}} */ q,
|
|
92
|
+
) => ({
|
|
93
|
+
kind: q.kind,
|
|
94
|
+
dropped: q.dropped,
|
|
95
|
+
exportUnconfirmed: q.exportUnconfirmed,
|
|
96
|
+
outstanding: q.outstanding,
|
|
97
|
+
capacity: q.capacity,
|
|
98
|
+
recentFailures: q.recentFailures,
|
|
99
|
+
failureHistoryEvicted: q.failureHistoryEvicted,
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
add(
|
|
103
|
+
"producer_delivery",
|
|
104
|
+
["metadata", "body"].every((kind) =>
|
|
105
|
+
failures.some((q) => q.kind === kind),
|
|
106
|
+
) &&
|
|
107
|
+
failures.every(
|
|
108
|
+
(q) =>
|
|
109
|
+
q.dropped === 0 &&
|
|
110
|
+
q.exportUnconfirmed === 0 &&
|
|
111
|
+
q.outstanding < q.capacity,
|
|
112
|
+
)
|
|
113
|
+
? "pass"
|
|
114
|
+
: "warn",
|
|
115
|
+
failures,
|
|
116
|
+
);
|
|
117
|
+
add(
|
|
118
|
+
"capture_admission",
|
|
119
|
+
logs?.bodyCapture &&
|
|
120
|
+
logs.bodyCapture.rejected === 0 &&
|
|
121
|
+
logs.bodyCapture.failed === 0
|
|
122
|
+
? "pass"
|
|
123
|
+
: "warn",
|
|
124
|
+
logs?.bodyCapture ?? { status: "unavailable" },
|
|
125
|
+
);
|
|
126
|
+
for (const [signal, table] of [
|
|
127
|
+
["logs", backend.stream],
|
|
128
|
+
["traces", backend.stream],
|
|
129
|
+
["metrics", "proxy_requests_total"],
|
|
130
|
+
]) {
|
|
131
|
+
const [value] = await query(
|
|
132
|
+
`SELECT COUNT(*) AS records, MAX(_timestamp) AS latest FROM "${table}"`,
|
|
133
|
+
signal,
|
|
134
|
+
);
|
|
135
|
+
add(
|
|
136
|
+
`${signal}_freshness`,
|
|
137
|
+
Number(value?.records) > 0 &&
|
|
138
|
+
Number.isFinite(Number(value?.latest)) &&
|
|
139
|
+
Number(value.latest) <= endTime &&
|
|
140
|
+
(endTime - Number(value.latest)) / 1e6 <= 120
|
|
141
|
+
? "pass"
|
|
142
|
+
: "unverified",
|
|
143
|
+
{
|
|
144
|
+
maxLagAtWindowEndSeconds: 120,
|
|
145
|
+
records: value?.records ?? 0,
|
|
146
|
+
latest: value?.latest,
|
|
147
|
+
ageAtWindowEndSeconds: value?.latest
|
|
148
|
+
? (endTime - Number(value.latest)) / 1e6
|
|
149
|
+
: null,
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
/** @type {Record<string, import("../../src/lib/types/index.js").ProxyTelemetryStoredRecord[]>} */
|
|
154
|
+
const history = Object.create(null);
|
|
155
|
+
for (const kind of ["request_final", "body_capture_index", "lifecycle"]) {
|
|
156
|
+
const result = await queryProxyHistory({
|
|
157
|
+
...backend,
|
|
158
|
+
startTime,
|
|
159
|
+
endTime,
|
|
160
|
+
kind,
|
|
161
|
+
maxRows,
|
|
162
|
+
fetchImpl,
|
|
163
|
+
budget,
|
|
164
|
+
});
|
|
165
|
+
queries.push(...result.queries.map((q) => ({ ...q, kind })));
|
|
166
|
+
history[kind] = result.records.map((row) => {
|
|
167
|
+
if (typeof row.body !== "string") {
|
|
168
|
+
throw new Error("Invalid stored metadata body");
|
|
169
|
+
}
|
|
170
|
+
const value = JSON.parse(row.body);
|
|
171
|
+
if (
|
|
172
|
+
!value ||
|
|
173
|
+
typeof value !== "object" ||
|
|
174
|
+
typeof value.requestId !== "string"
|
|
175
|
+
) {
|
|
176
|
+
throw new Error("Stored telemetry metadata has an invalid schema");
|
|
177
|
+
}
|
|
178
|
+
return value;
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const finals = history.request_final,
|
|
182
|
+
unique = new Set(finals.map((r) => r.requestId));
|
|
183
|
+
add(
|
|
184
|
+
"final_uniqueness",
|
|
185
|
+
!finals.length
|
|
186
|
+
? "unverified"
|
|
187
|
+
: unique.size === finals.length
|
|
188
|
+
? "pass"
|
|
189
|
+
: "fail",
|
|
190
|
+
{ records: finals.length, uniqueRequestIds: unique.size },
|
|
191
|
+
);
|
|
192
|
+
const terminalEvents = history.lifecycle.filter(
|
|
193
|
+
(row) => row.event === "request_terminal",
|
|
194
|
+
);
|
|
195
|
+
const missingFinals = terminalEvents.filter(
|
|
196
|
+
(row) =>
|
|
197
|
+
(row.telemetryStatus !== undefined &&
|
|
198
|
+
row.telemetryStatus !== "complete") ||
|
|
199
|
+
((row.outcomeSource === "final_request" ||
|
|
200
|
+
row.outcomeSource === undefined) &&
|
|
201
|
+
!unique.has(row.requestId)),
|
|
202
|
+
);
|
|
203
|
+
add(
|
|
204
|
+
"terminal_reconciliation",
|
|
205
|
+
!terminalEvents.length
|
|
206
|
+
? "unverified"
|
|
207
|
+
: missingFinals.length
|
|
208
|
+
? "fail"
|
|
209
|
+
: "pass",
|
|
210
|
+
{
|
|
211
|
+
terminals: terminalEvents.length,
|
|
212
|
+
incompleteOrMissingFinal: missingFinals.length,
|
|
213
|
+
requestIds: missingFinals.slice(0, 50).map((row) => row.requestId),
|
|
214
|
+
boundary:
|
|
215
|
+
"Terminals inside the selected interval; admissions still in flight are not failures",
|
|
216
|
+
},
|
|
217
|
+
);
|
|
218
|
+
/** @type {Record<string, import("../../src/lib/types/index.js").ProxyTelemetryFieldCoverage>} */
|
|
219
|
+
const coverage = Object.create(null);
|
|
220
|
+
for (const row of finals) {
|
|
221
|
+
const group = (coverage[row.model ?? "unknown"] ??= {
|
|
222
|
+
records: 0,
|
|
223
|
+
traceMissing: 0,
|
|
224
|
+
durationMissing: 0,
|
|
225
|
+
outcomeMissing: 0,
|
|
226
|
+
firstOutputUnexplained: 0,
|
|
227
|
+
});
|
|
228
|
+
group.records++;
|
|
229
|
+
if (
|
|
230
|
+
!/^[a-f0-9]{32}$/i.test(row.traceId ?? "") ||
|
|
231
|
+
/^0+$/.test(row.traceId ?? "")
|
|
232
|
+
) {
|
|
233
|
+
group.traceMissing++;
|
|
234
|
+
}
|
|
235
|
+
if (
|
|
236
|
+
!Number.isFinite(row.responseTimeMs) ||
|
|
237
|
+
(row.responseTimeMs ?? -1) < 0
|
|
238
|
+
) {
|
|
239
|
+
group.durationMissing++;
|
|
240
|
+
}
|
|
241
|
+
if (
|
|
242
|
+
![
|
|
243
|
+
"completed",
|
|
244
|
+
"bodyless",
|
|
245
|
+
"client_cancelled",
|
|
246
|
+
"stream_error",
|
|
247
|
+
"handler_error",
|
|
248
|
+
].includes(row.terminalOutcome ?? "")
|
|
249
|
+
) {
|
|
250
|
+
group.outcomeMissing++;
|
|
251
|
+
}
|
|
252
|
+
const timing = row.firstUsefulOutputMs;
|
|
253
|
+
const validTiming =
|
|
254
|
+
Number.isFinite(timing) &&
|
|
255
|
+
(timing ?? -1) >= 0 &&
|
|
256
|
+
(timing ?? Infinity) <= (row.responseTimeMs ?? -1);
|
|
257
|
+
const knownAbsent =
|
|
258
|
+
row.firstUsefulOutputStatus === "no_useful_output" &&
|
|
259
|
+
timing === undefined &&
|
|
260
|
+
row.firstUsefulOutputEvent === undefined;
|
|
261
|
+
const validAvailability =
|
|
262
|
+
row.firstUsefulOutputStatus === undefined ||
|
|
263
|
+
["observed", "no_useful_output", "not_observed"].includes(
|
|
264
|
+
row.firstUsefulOutputStatus,
|
|
265
|
+
);
|
|
266
|
+
if (
|
|
267
|
+
!validAvailability ||
|
|
268
|
+
(timing !== undefined && !validTiming) ||
|
|
269
|
+
(row.firstUsefulOutputStatus === "observed" && !validTiming) ||
|
|
270
|
+
(row.firstUsefulOutputStatus === "no_useful_output" && !knownAbsent) ||
|
|
271
|
+
(row.terminalOutcome === "completed" &&
|
|
272
|
+
!(validTiming && row.firstUsefulOutputStatus !== "not_observed") &&
|
|
273
|
+
!knownAbsent)
|
|
274
|
+
) {
|
|
275
|
+
group.firstOutputUnexplained++;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
add(
|
|
279
|
+
"request_field_coverage",
|
|
280
|
+
!finals.length
|
|
281
|
+
? "unverified"
|
|
282
|
+
: Object.values(coverage).every(
|
|
283
|
+
(g) =>
|
|
284
|
+
g.traceMissing +
|
|
285
|
+
g.durationMissing +
|
|
286
|
+
g.outcomeMissing +
|
|
287
|
+
g.firstOutputUnexplained ===
|
|
288
|
+
0,
|
|
289
|
+
)
|
|
290
|
+
? "pass"
|
|
291
|
+
: "fail",
|
|
292
|
+
coverage,
|
|
293
|
+
);
|
|
294
|
+
const indexes = history.body_capture_index;
|
|
295
|
+
/** @type {Record<string, number>} */
|
|
296
|
+
const byDelivery = Object.create(null);
|
|
297
|
+
for (const row of indexes) {
|
|
298
|
+
const status = row.bodyDelivery?.status ?? "missing";
|
|
299
|
+
byDelivery[status] = (byDelivery[status] ?? 0) + 1;
|
|
300
|
+
}
|
|
301
|
+
const captureFailures = indexes.filter(
|
|
302
|
+
(row) =>
|
|
303
|
+
row.captureError ||
|
|
304
|
+
row.bodyTruncated ||
|
|
305
|
+
!["transport_acknowledged", "policy_excluded", "no_body"].includes(
|
|
306
|
+
row.bodyDelivery?.status ?? "",
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
add(
|
|
310
|
+
"capture_delivery",
|
|
311
|
+
!indexes.length ? "unverified" : captureFailures.length ? "fail" : "pass",
|
|
312
|
+
{
|
|
313
|
+
records: indexes.length,
|
|
314
|
+
byDelivery,
|
|
315
|
+
failures: captureFailures.slice(0, 50).map((row) => ({
|
|
316
|
+
requestId: row.requestId,
|
|
317
|
+
captureId: row.captureId,
|
|
318
|
+
phase: row.phase,
|
|
319
|
+
captureError: row.captureError,
|
|
320
|
+
captureAdmission: row.captureAdmission,
|
|
321
|
+
bodyTruncated: row.bodyTruncated,
|
|
322
|
+
bodyDelivery: row.bodyDelivery,
|
|
323
|
+
})),
|
|
324
|
+
failuresOmitted: Math.max(0, captureFailures.length - 50),
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
const bodyChecks = [];
|
|
328
|
+
for (const index of indexes
|
|
329
|
+
.filter(
|
|
330
|
+
(row) =>
|
|
331
|
+
row.bodySha256 && row.bodyDelivery?.status === "transport_acknowledged",
|
|
332
|
+
)
|
|
333
|
+
.sort((a, b) => (b.redactedBodyBytes ?? 0) - (a.redactedBodyBytes ?? 0))
|
|
334
|
+
.slice(0, 3)) {
|
|
335
|
+
if (!/^[a-f0-9-]{36}$/i.test(index.captureId ?? "")) {
|
|
336
|
+
throw new Error("Invalid capture identifier");
|
|
337
|
+
}
|
|
338
|
+
if (
|
|
339
|
+
!Number.isSafeInteger(index.redactedBodyBytes) ||
|
|
340
|
+
(index.redactedBodyBytes ?? -1) < 0 ||
|
|
341
|
+
(index.redactedBodyBytes ?? 0) > 8 * 1024 * 1024
|
|
342
|
+
) {
|
|
343
|
+
throw new Error("Capture verification exceeds the 8 MiB per-body bound");
|
|
344
|
+
}
|
|
345
|
+
const chunks = await query(
|
|
346
|
+
`SELECT body_chunk_index, body_chunk_count, body FROM "${backend.stream}" WHERE proxy_record_kind='body' AND body_capture_id='${index.captureId}' ORDER BY body_chunk_index ASC`,
|
|
347
|
+
"logs",
|
|
348
|
+
{
|
|
349
|
+
startTime: startTime - 120e6,
|
|
350
|
+
endTime: Math.min(Date.now() * 1000, endTime + 120e6),
|
|
351
|
+
size: 1000,
|
|
352
|
+
},
|
|
353
|
+
);
|
|
354
|
+
if (chunks.length >= 1000) {
|
|
355
|
+
throw new Error("Capture verification exceeded its chunk budget");
|
|
356
|
+
}
|
|
357
|
+
const bytes = chunks.reduce(
|
|
358
|
+
(sum, r) =>
|
|
359
|
+
sum +
|
|
360
|
+
(typeof r.body === "string" ? Buffer.byteLength(r.body) : Infinity),
|
|
361
|
+
0,
|
|
362
|
+
);
|
|
363
|
+
if (bytes > 8 * 1024 * 1024) {
|
|
364
|
+
throw new Error("Stored capture exceeds the 8 MiB verification bound");
|
|
365
|
+
}
|
|
366
|
+
const raw = chunks.map((r) => r.body).join(""),
|
|
367
|
+
expected = Number(chunks[0]?.body_chunk_count ?? 0);
|
|
368
|
+
const contiguous = chunks.every(
|
|
369
|
+
(row, i) =>
|
|
370
|
+
Number(row.body_chunk_index) === i &&
|
|
371
|
+
Number(row.body_chunk_count) === expected,
|
|
372
|
+
);
|
|
373
|
+
const actualSha256 = createHash("sha256").update(raw).digest("hex");
|
|
374
|
+
bodyChecks.push({
|
|
375
|
+
captureId: index.captureId,
|
|
376
|
+
expectedChunks: expected,
|
|
377
|
+
storedChunks: chunks.length,
|
|
378
|
+
verified:
|
|
379
|
+
expected > 0 &&
|
|
380
|
+
expected === chunks.length &&
|
|
381
|
+
contiguous &&
|
|
382
|
+
Buffer.byteLength(raw) === index.redactedBodyBytes &&
|
|
383
|
+
actualSha256 === index.bodySha256,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
add(
|
|
387
|
+
"sample_body_integrity",
|
|
388
|
+
!bodyChecks.length
|
|
389
|
+
? "unverified"
|
|
390
|
+
: bodyChecks.every((r) => r.verified)
|
|
391
|
+
? "pass"
|
|
392
|
+
: "fail",
|
|
393
|
+
bodyChecks,
|
|
394
|
+
);
|
|
395
|
+
const traceIds = [
|
|
396
|
+
...new Set(
|
|
397
|
+
finals
|
|
398
|
+
.map((row) => row.traceId)
|
|
399
|
+
.filter((id) => /^[a-f0-9]{32}$/i.test(id ?? "")),
|
|
400
|
+
),
|
|
401
|
+
].slice(-3);
|
|
402
|
+
const correlated = traceIds.length
|
|
403
|
+
? await query(
|
|
404
|
+
`SELECT trace_id, COUNT(*) AS spans FROM "${backend.stream}" WHERE trace_id IN (${traceIds.map((id) => `'${id}'`).join(",")}) GROUP BY trace_id`,
|
|
405
|
+
"traces",
|
|
406
|
+
{
|
|
407
|
+
startTime: startTime - 120e6,
|
|
408
|
+
endTime: Math.min(Date.now() * 1000, endTime + 120e6),
|
|
409
|
+
},
|
|
410
|
+
)
|
|
411
|
+
: [];
|
|
412
|
+
add(
|
|
413
|
+
"sample_trace_correlation",
|
|
414
|
+
!traceIds.length
|
|
415
|
+
? "unverified"
|
|
416
|
+
: traceIds.every((id) =>
|
|
417
|
+
correlated.some(
|
|
418
|
+
(row) => row.trace_id === id && Number(row.spans) > 0,
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
? "pass"
|
|
422
|
+
: "fail",
|
|
423
|
+
{ requested: traceIds.length, found: correlated.length },
|
|
424
|
+
);
|
|
425
|
+
if (backend.collectorMetricsUrl) {
|
|
426
|
+
const url = new URL(backend.collectorMetricsUrl);
|
|
427
|
+
if (
|
|
428
|
+
!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) ||
|
|
429
|
+
!["http:", "https:"].includes(url.protocol)
|
|
430
|
+
) {
|
|
431
|
+
throw new Error("Collector diagnostics must use loopback HTTP(S)");
|
|
432
|
+
}
|
|
433
|
+
const metrics = await fetchImpl(url, {
|
|
434
|
+
redirect: "error",
|
|
435
|
+
signal: globalThis.AbortSignal.timeout(10000),
|
|
436
|
+
});
|
|
437
|
+
if (!metrics.ok) {
|
|
438
|
+
throw new Error(`Collector diagnostics failed: HTTP ${metrics.status}`);
|
|
439
|
+
}
|
|
440
|
+
const counters = Object.fromEntries(
|
|
441
|
+
(await metrics.text())
|
|
442
|
+
.split("\n")
|
|
443
|
+
.filter((line) =>
|
|
444
|
+
/^otelcol_(exporter_(queue_size|sent|send_failed|enqueue_failed)|receiver_(refused|failed))/.test(
|
|
445
|
+
line,
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
.map((line) => {
|
|
449
|
+
const match = /^(\w+(?:\{.*\})?)\s+(\S+)(?:\s+\d+)?$/.exec(line);
|
|
450
|
+
return [match?.[1] ?? "invalid_sample", Number(match?.[2])];
|
|
451
|
+
}),
|
|
452
|
+
);
|
|
453
|
+
add(
|
|
454
|
+
"collector_delivery",
|
|
455
|
+
["log_records", "spans", "metric_points"].every((signal) =>
|
|
456
|
+
Object.keys(counters).some(
|
|
457
|
+
(key) =>
|
|
458
|
+
key.startsWith(`otelcol_exporter_send_failed_${signal}`) ||
|
|
459
|
+
key.startsWith(`otelcol_exporter_sent_${signal}`),
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
? Object.entries(counters).some(
|
|
463
|
+
([key, value]) =>
|
|
464
|
+
!Number.isFinite(value) ||
|
|
465
|
+
value < 0 ||
|
|
466
|
+
(/^otelcol_(exporter_(send_failed|enqueue_failed)|receiver_(refused|failed))/.test(
|
|
467
|
+
key,
|
|
468
|
+
) &&
|
|
469
|
+
value > 0),
|
|
470
|
+
)
|
|
471
|
+
? "warn"
|
|
472
|
+
: "pass"
|
|
473
|
+
: "unverified",
|
|
474
|
+
{
|
|
475
|
+
scope:
|
|
476
|
+
"collector lifetime delivery/failure counters; absent failure series are not required when sent series identify that signal; queue_size is an instantaneous gauge and a nonzero queue alone is not a failure",
|
|
477
|
+
counters,
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
} else {
|
|
481
|
+
add("collector_delivery", "unverified", {
|
|
482
|
+
reason: "collector metrics endpoint not configured",
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
schemaVersion: 1,
|
|
487
|
+
checkedAt: new Date().toISOString(),
|
|
488
|
+
startTime,
|
|
489
|
+
endTimeExclusive: endTime,
|
|
490
|
+
source: "OpenObserve query API over stored OTLP telemetry",
|
|
491
|
+
completeQuery: true,
|
|
492
|
+
status: checks.some((c) => c.status === "fail")
|
|
493
|
+
? "fail"
|
|
494
|
+
: checks.some((c) => c.status !== "pass")
|
|
495
|
+
? "incomplete"
|
|
496
|
+
: "pass",
|
|
497
|
+
guarantee:
|
|
498
|
+
"Coverage and bounded sample verification, not exactly-once or universal lossless delivery",
|
|
499
|
+
checks,
|
|
500
|
+
queries,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Shipped CLI and repository doctor use the same implementation. */
|
|
505
|
+
export async function runProxyTelemetryDoctor() {
|
|
506
|
+
const args = process.argv.slice(2);
|
|
507
|
+
/** @param {string} name */
|
|
508
|
+
const value = (name) => {
|
|
509
|
+
const i = args.indexOf(name);
|
|
510
|
+
return i < 0 ? undefined : args[i + 1];
|
|
511
|
+
};
|
|
512
|
+
if (args.includes("--help")) {
|
|
513
|
+
console.log(
|
|
514
|
+
"Usage: neurolink proxy telemetry doctor [--since ISO_DATE] [--until ISO_DATE] [--format json|text] [--proxy-url URL] [--max-rows 10000]",
|
|
515
|
+
);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const until = value("--until")
|
|
519
|
+
? Date.parse(value("--until") ?? "")
|
|
520
|
+
: Date.now() - 30000;
|
|
521
|
+
const since = value("--since")
|
|
522
|
+
? Date.parse(value("--since") ?? "")
|
|
523
|
+
: until - 15 * 60000;
|
|
524
|
+
const report = await checkProxyTelemetry({
|
|
525
|
+
backend: await resolveProxyTelemetryBackend(),
|
|
526
|
+
startTime: since * 1000,
|
|
527
|
+
endTime: until * 1000,
|
|
528
|
+
proxyUrl: value("--proxy-url") ?? process.env.NEUROLINK_PROXY_URL,
|
|
529
|
+
maxRows: Number(value("--max-rows") ?? 10000),
|
|
530
|
+
});
|
|
531
|
+
if (value("--format") === "json") {
|
|
532
|
+
console.log(JSON.stringify(report, null, 2));
|
|
533
|
+
} else {
|
|
534
|
+
console.log(`NeuroLink OTel coverage: ${report.status}`);
|
|
535
|
+
for (const check of report.checks) {
|
|
536
|
+
console.log(
|
|
537
|
+
`${check.status.padEnd(10)} ${check.name}: ${JSON.stringify(check.evidence)}`,
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (report.status !== "pass") {
|
|
542
|
+
process.exitCode = 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** Bounded, deterministic OpenObserve history queries; never accept partial data. */
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
|
+
import {
|
|
5
|
+
resolveProxyTelemetryBackend,
|
|
6
|
+
validateProxyTelemetryBackend,
|
|
7
|
+
} from "./proxy-telemetry-backend.mjs";
|
|
4
8
|
|
|
5
9
|
const KINDS = new Set([
|
|
6
10
|
"request_final",
|
|
@@ -9,11 +13,13 @@ const KINDS = new Set([
|
|
|
9
13
|
"supervisor",
|
|
10
14
|
"body_capture_index",
|
|
11
15
|
"stream_error",
|
|
16
|
+
"telemetry_delivery",
|
|
17
|
+
"console",
|
|
12
18
|
]);
|
|
13
19
|
|
|
14
20
|
/**
|
|
15
21
|
* Query metadata in small windows; body chunks require a targeted lookup.
|
|
16
|
-
* @param {{ baseUrl: string, organization?: string, stream?: string, authorization?: string, startTime: number, endTime: number, kind?: string, maxRows?: number, fetchImpl?: typeof fetch }} options
|
|
22
|
+
* @param {{ baseUrl: string, organization?: string, stream?: string, authorization?: string, startTime: number, endTime: number, kind?: string, maxRows?: number, budget?: import("../../src/lib/types/index.js").ProxyTelemetryQueryBudget, fetchImpl?: typeof fetch }} options
|
|
17
23
|
*/
|
|
18
24
|
export async function queryProxyHistory({
|
|
19
25
|
baseUrl,
|
|
@@ -25,6 +31,7 @@ export async function queryProxyHistory({
|
|
|
25
31
|
kind = "request_final",
|
|
26
32
|
maxRows = 10_000,
|
|
27
33
|
fetchImpl = fetch,
|
|
34
|
+
budget = { used: 0, limit: 512 },
|
|
28
35
|
}) {
|
|
29
36
|
if (
|
|
30
37
|
!/^[a-zA-Z0-9_-]+$/.test(organization) ||
|
|
@@ -45,12 +52,24 @@ export async function queryProxyHistory({
|
|
|
45
52
|
"Provide an increasing microsecond time range and maxRows between 1 and 100000",
|
|
46
53
|
);
|
|
47
54
|
}
|
|
55
|
+
validateProxyTelemetryBackend({
|
|
56
|
+
baseUrl,
|
|
57
|
+
organization,
|
|
58
|
+
stream,
|
|
59
|
+
authorization,
|
|
60
|
+
});
|
|
48
61
|
const endpoint = new URL(`/api/${organization}/_search?type=logs`, baseUrl);
|
|
49
|
-
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(
|
|
63
|
+
endpoint.hostname,
|
|
64
|
+
);
|
|
65
|
+
if (
|
|
66
|
+
authorization &&
|
|
67
|
+
endpoint.protocol !== "https:" &&
|
|
68
|
+
!(endpoint.protocol === "http:" && loopback)
|
|
69
|
+
) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"Credentialed OpenObserve queries require HTTPS outside loopback",
|
|
72
|
+
);
|
|
54
73
|
}
|
|
55
74
|
/** @type {Array<{start: number, endExclusive: number, offset: number, partial: boolean, tookMs?: number}>} */
|
|
56
75
|
const queries = [];
|
|
@@ -59,14 +78,15 @@ export async function queryProxyHistory({
|
|
|
59
78
|
async function readWindow(start, end) {
|
|
60
79
|
const rows = [];
|
|
61
80
|
for (let offset = 0; ; offset += 200) {
|
|
62
|
-
if (
|
|
81
|
+
if (budget.used >= budget.limit) {
|
|
63
82
|
throw new Error(
|
|
64
83
|
"History exceeds the 512-query budget; narrow the interval",
|
|
65
84
|
);
|
|
66
85
|
}
|
|
86
|
+
budget.used++;
|
|
67
87
|
const response = await fetchImpl(endpoint, {
|
|
68
88
|
method: "POST",
|
|
69
|
-
redirect:
|
|
89
|
+
redirect: "error",
|
|
70
90
|
signal: globalThis.AbortSignal.timeout(30_000),
|
|
71
91
|
headers: {
|
|
72
92
|
"Content-Type": "application/json",
|
|
@@ -162,18 +182,9 @@ async function main() {
|
|
|
162
182
|
const since = Date.parse(value("--since") ?? "");
|
|
163
183
|
const untilValue = value("--until");
|
|
164
184
|
const until = untilValue ? Date.parse(untilValue) : Date.now();
|
|
165
|
-
const
|
|
166
|
-
const password = process.env.NEUROLINK_OPENOBSERVE_PASSWORD;
|
|
167
|
-
const authorization =
|
|
168
|
-
process.env.NEUROLINK_OPENOBSERVE_BASIC_AUTH ??
|
|
169
|
-
(user && password
|
|
170
|
-
? `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`
|
|
171
|
-
: undefined);
|
|
185
|
+
const backend = await resolveProxyTelemetryBackend();
|
|
172
186
|
const report = await queryProxyHistory({
|
|
173
|
-
|
|
174
|
-
organization: process.env.NEUROLINK_OPENOBSERVE_ORG ?? "default",
|
|
175
|
-
stream: process.env.NEUROLINK_PROXY_STREAM_HEADER ?? "neurolink_proxy",
|
|
176
|
-
authorization,
|
|
187
|
+
...backend,
|
|
177
188
|
startTime: since * 1000,
|
|
178
189
|
endTime: until * 1000,
|
|
179
190
|
kind: value("--kind") ?? "request_final",
|