@juspay/neurolink 12.12.9 → 12.12.10
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 +394 -395
- package/dist/cli/commands/proxy.d.ts +4 -0
- package/dist/cli/commands/proxy.js +60 -18
- package/dist/cli/commands/proxyAnalyze.js +8 -1
- package/dist/proxy/bodyCaptureProcessing.d.ts +22 -0
- package/dist/proxy/bodyCaptureProcessing.js +219 -0
- package/dist/proxy/bodyCaptureWorker.d.ts +16 -0
- package/dist/proxy/bodyCaptureWorker.js +232 -0
- package/dist/proxy/bodyCaptureWorkerEntry.d.ts +1 -0
- package/dist/proxy/bodyCaptureWorkerEntry.js +34 -0
- package/dist/proxy/proxyAnalysis.js +159 -17
- package/dist/proxy/proxyLifecycle.d.ts +25 -0
- package/dist/proxy/proxyLifecycle.js +111 -5
- package/dist/proxy/proxyRequestKind.d.ts +2 -0
- package/dist/proxy/proxyRequestKind.js +6 -0
- package/dist/proxy/proxyRuntimeMetrics.d.ts +3 -0
- package/dist/proxy/proxyRuntimeMetrics.js +34 -0
- package/dist/proxy/requestLogger.d.ts +10 -6
- package/dist/proxy/requestLogger.js +99 -231
- package/dist/proxy/rollingProxyServer.js +15 -4
- package/dist/proxy/rollingWorkerProcess.d.ts +4 -0
- package/dist/proxy/rollingWorkerProcess.js +25 -8
- package/dist/proxy/rollingWorkerProtocol.d.ts +6 -0
- package/dist/proxy/rollingWorkerProtocol.js +12 -1
- package/dist/proxy/rollingWorkerSupervisor.d.ts +28 -0
- package/dist/proxy/rollingWorkerSupervisor.js +73 -25
- package/dist/proxy/socketWorkerRuntime.d.ts +5 -0
- package/dist/proxy/socketWorkerRuntime.js +19 -2
- package/dist/server/routes/codexProxyRoutes.js +39 -3
- package/dist/services/server/ai/observability/instrumentation.js +7 -1
- package/dist/types/cli.d.ts +1 -1
- package/dist/types/proxy.d.ts +69 -4
- package/package.json +1 -1
|
@@ -60,6 +60,10 @@ export declare function isRollingHandoffCapable(state: ProxySupervisorState | nu
|
|
|
60
60
|
export declare function processLooksLikeProxySupervisor(pid: number, expectedStartTimeIso?: string): Promise<boolean>;
|
|
61
61
|
export declare function probeProxyHealth(host: string, port: number, timeoutMs: number): Promise<ProxyHealthProbe>;
|
|
62
62
|
export declare function mapClaudeErrorTypeToStatus(errorType?: string): number;
|
|
63
|
+
/**
|
|
64
|
+
* Assemble proxy routes with shared admission, runtime error accounting,
|
|
65
|
+
* and response tracking.
|
|
66
|
+
*/
|
|
63
67
|
export declare function createProxyStartApp(params: {
|
|
64
68
|
neurolink: ProxyNeurolinkRuntime["neurolink"];
|
|
65
69
|
modelRouter: ModelRouterInterface | undefined;
|
|
@@ -27,11 +27,12 @@ import { startProxyLogCleanupScheduler } from "../../proxy/logCleanupScheduler.j
|
|
|
27
27
|
import { anthropicAccountKeysEqual, createAccountAllowlist, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, shouldLoadFallbackCredential, } from "../../proxy/accountSelection.js";
|
|
28
28
|
import { resolveProxyStatusAccountIdentity } from "../../proxy/codexAccountUsage.js";
|
|
29
29
|
import { beginProxyRequest, getProxyActivitySnapshot, observeProxyFinalLog, takeProxyResponseObservers, trackProxyResponse, } from "../../proxy/proxyActivity.js";
|
|
30
|
-
import { flushProxyLifecycleEvents, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../proxy/proxyLifecycle.js";
|
|
30
|
+
import { configureProxyLifecycleLogger, flushProxyLifecycleEvents, persistProxyLifecycleAcceptance, getProxyLifecycleLoggerSnapshot, hashProxyLifecycleSessionId, logProxyLifecycleEvent, } from "../../proxy/proxyLifecycle.js";
|
|
31
31
|
import { describeInstallFailure, getGlobalInstallArgs, isTransientInstallFailure, resolveGlobalInstaller, validateInstalledVersion, } from "../../proxy/globalInstaller.js";
|
|
32
32
|
import { startUpdaterWorkerSupervisor } from "../../proxy/updaterSupervisor.js";
|
|
33
33
|
import { openProxyWorkerLog } from "../../proxy/workerLog.js";
|
|
34
34
|
import { startRollingProxyServer } from "../../proxy/rollingProxyServer.js";
|
|
35
|
+
import { isProxyAuxiliaryRequest } from "../../proxy/proxyRequestKind.js";
|
|
35
36
|
import { spawnProxySocketWorker } from "../../proxy/rollingWorkerProcess.js";
|
|
36
37
|
import { PROXY_ROLLING_SUPERVISOR_ENV, PROXY_SOCKET_WORKER_ENV, } from "../../proxy/rollingWorkerProtocol.js";
|
|
37
38
|
import { attachSocketWorkerProcess } from "../../proxy/socketWorkerRuntime.js";
|
|
@@ -1056,7 +1057,12 @@ function redactStatusPrimaryAccount(primary) {
|
|
|
1056
1057
|
source: primary.source,
|
|
1057
1058
|
};
|
|
1058
1059
|
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Confirm durable admission and reconcile route finals with the actual
|
|
1062
|
+
* response transport lifecycle.
|
|
1063
|
+
*/
|
|
1059
1064
|
function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
1065
|
+
/** Persist admission before dispatch and observe the response through termination. */
|
|
1060
1066
|
const trackingHandler = async (c, next) => {
|
|
1061
1067
|
const startedMonotonicMs = performance.now();
|
|
1062
1068
|
const contentLengthHeader = c.req.raw.headers.get("content-length");
|
|
@@ -1097,17 +1103,16 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1097
1103
|
// The route adapter populates model/stream/toolCount after parsing. Omit
|
|
1098
1104
|
// them at acceptance instead of publishing misleading placeholder values;
|
|
1099
1105
|
// subsequent events carry the parsed metadata under the same request ID.
|
|
1100
|
-
logProxyLifecycleEvent({
|
|
1101
|
-
event: "request_accepted",
|
|
1102
|
-
requestId: metadata.requestId,
|
|
1103
|
-
method: metadata.method,
|
|
1104
|
-
path: metadata.path,
|
|
1105
|
-
sessionHash,
|
|
1106
|
-
requestBytes,
|
|
1107
|
-
elapsedMs: 0,
|
|
1108
|
-
monotonicMs: startedMonotonicMs,
|
|
1109
|
-
});
|
|
1110
1106
|
try {
|
|
1107
|
+
await persistProxyLifecycleAcceptance({
|
|
1108
|
+
requestId: metadata.requestId,
|
|
1109
|
+
method: metadata.method,
|
|
1110
|
+
path: metadata.path,
|
|
1111
|
+
sessionHash,
|
|
1112
|
+
requestBytes,
|
|
1113
|
+
elapsedMs: 0,
|
|
1114
|
+
monotonicMs: startedMonotonicMs,
|
|
1115
|
+
});
|
|
1111
1116
|
await next();
|
|
1112
1117
|
const responseStatus = c.res.status;
|
|
1113
1118
|
logProxyLifecycleEvent({
|
|
@@ -1180,6 +1185,7 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1180
1185
|
accountingTimedOut = true;
|
|
1181
1186
|
}
|
|
1182
1187
|
const final = metadata.terminalResult;
|
|
1188
|
+
const auxiliary = isProxyAuxiliaryRequest(metadata.method, metadata.path);
|
|
1183
1189
|
const terminalOutcome = final?.terminalOutcome ??
|
|
1184
1190
|
(outcome === "stream_error" ||
|
|
1185
1191
|
metadata.terminalErrorType === "stream_error"
|
|
@@ -1188,7 +1194,9 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1188
1194
|
? "client_cancelled"
|
|
1189
1195
|
: responseStatus >= 400
|
|
1190
1196
|
? "handler_error"
|
|
1191
|
-
:
|
|
1197
|
+
: auxiliary
|
|
1198
|
+
? outcome
|
|
1199
|
+
: "unknown");
|
|
1192
1200
|
logProxyLifecycleEvent({
|
|
1193
1201
|
event: "request_terminal",
|
|
1194
1202
|
timestampMs: terminalTimestampMs,
|
|
@@ -1210,7 +1218,9 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1210
1218
|
transportOutcome: outcome,
|
|
1211
1219
|
outcomeSource: final
|
|
1212
1220
|
? "final_request"
|
|
1213
|
-
: responseStatus >= 400
|
|
1221
|
+
: responseStatus >= 400 ||
|
|
1222
|
+
(auxiliary &&
|
|
1223
|
+
(outcome === "completed" || outcome === "bodyless"))
|
|
1214
1224
|
? "http_status"
|
|
1215
1225
|
: terminalOutcome === "unknown"
|
|
1216
1226
|
? "unknown"
|
|
@@ -1219,7 +1229,7 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1219
1229
|
? "timeout"
|
|
1220
1230
|
: accountingFailed
|
|
1221
1231
|
? "observer_error"
|
|
1222
|
-
: final
|
|
1232
|
+
: final || auxiliary
|
|
1223
1233
|
? "complete"
|
|
1224
1234
|
: "missing_final",
|
|
1225
1235
|
errorType: final?.errorType ?? metadata.terminalErrorType,
|
|
@@ -1270,6 +1280,10 @@ function registerProxyRequestTracking(app, requestMetadata, readiness) {
|
|
|
1270
1280
|
app.use("/v1beta/*", trackingHandler);
|
|
1271
1281
|
app.use("/backend-api/*", trackingHandler);
|
|
1272
1282
|
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Assemble proxy routes with shared admission, runtime error accounting,
|
|
1285
|
+
* and response tracking.
|
|
1286
|
+
*/
|
|
1273
1287
|
export async function createProxyStartApp(params) {
|
|
1274
1288
|
const { createClaudeProxyRoutes } = await import("../../server/routes/claudeProxyRoutes.js");
|
|
1275
1289
|
const { createOpenAIProxyRoutes } = await import("../../server/routes/openaiProxyRoutes.js");
|
|
@@ -1356,9 +1370,13 @@ export async function createProxyStartApp(params) {
|
|
|
1356
1370
|
stream: false,
|
|
1357
1371
|
toolCount: 0,
|
|
1358
1372
|
};
|
|
1359
|
-
metadata.terminalErrorType = "unhandled_proxy_error";
|
|
1360
1373
|
metadata.terminalErrorCode = getProxyRuntimeErrorCode(err);
|
|
1361
|
-
|
|
1374
|
+
const telemetryUnavailable = metadata.terminalErrorCode === "PROXY_TELEMETRY_UNAVAILABLE";
|
|
1375
|
+
const status = telemetryUnavailable ? 503 : 502;
|
|
1376
|
+
metadata.terminalErrorType = telemetryUnavailable
|
|
1377
|
+
? "telemetry_unavailable"
|
|
1378
|
+
: "unhandled_proxy_error";
|
|
1379
|
+
await recordRuntimeError(metadata, status, metadata.terminalErrorType, errMsg, {
|
|
1362
1380
|
clientMessage: "Proxy internal error",
|
|
1363
1381
|
clientErrorType: "api_error",
|
|
1364
1382
|
errorCode: metadata.terminalErrorCode,
|
|
@@ -1370,7 +1388,7 @@ export async function createProxyStartApp(params) {
|
|
|
1370
1388
|
type: "api_error",
|
|
1371
1389
|
message: "Proxy internal error",
|
|
1372
1390
|
},
|
|
1373
|
-
},
|
|
1391
|
+
}, status);
|
|
1374
1392
|
});
|
|
1375
1393
|
app.post("/internal/update-control", async (c) => {
|
|
1376
1394
|
const suppliedToken = c.req.header("x-neurolink-update-token");
|
|
@@ -2270,6 +2288,7 @@ function startProxyBackgroundMaintenance(logsDir, getAccountAllowlist) {
|
|
|
2270
2288
|
const logCleanupScheduler = startProxyLogCleanupScheduler({ logsDir });
|
|
2271
2289
|
return { refreshInterval, logCleanupScheduler };
|
|
2272
2290
|
}
|
|
2291
|
+
/** Register signal handlers and return the shared, idempotent drain-and-flush operation. */
|
|
2273
2292
|
function registerProxyShutdownHandlers(params) {
|
|
2274
2293
|
let shutdownStarted = false;
|
|
2275
2294
|
/**
|
|
@@ -2329,6 +2348,7 @@ function registerProxyShutdownHandlers(params) {
|
|
|
2329
2348
|
}
|
|
2330
2349
|
});
|
|
2331
2350
|
};
|
|
2351
|
+
/** Stop background work, drain connections, and flush pending telemetry before exit. */
|
|
2332
2352
|
const shutdown = async (signal, options) => {
|
|
2333
2353
|
if (shutdownStarted) {
|
|
2334
2354
|
return;
|
|
@@ -2359,7 +2379,9 @@ function registerProxyShutdownHandlers(params) {
|
|
|
2359
2379
|
const [usageStatsFlushResult, lifecycleFlushResult, requestLogsFlushResult,] = await Promise.allSettled([
|
|
2360
2380
|
withTimeout(usageStatsFlush, PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy usage statistics during shutdown"),
|
|
2361
2381
|
withTimeout(flushProxyLifecycleEvents(), PROXY_LIFECYCLE_SHUTDOWN_TIMEOUT_MS, "Timed out flushing proxy lifecycle metadata during shutdown"),
|
|
2362
|
-
|
|
2382
|
+
// This flush owns a separate bounded budget covering the body worker
|
|
2383
|
+
// deadline plus index/OTLP publication; do not truncate it to five seconds.
|
|
2384
|
+
requestLogsFlush,
|
|
2363
2385
|
]);
|
|
2364
2386
|
if (usageStatsFlushResult.status === "rejected") {
|
|
2365
2387
|
const error = usageStatsFlushResult.reason;
|
|
@@ -2413,6 +2435,7 @@ function registerProxyShutdownHandlers(params) {
|
|
|
2413
2435
|
}
|
|
2414
2436
|
return shutdown;
|
|
2415
2437
|
}
|
|
2438
|
+
/** Start the HTTP runtime and connect its worker, configuration, and shutdown lifecycles. */
|
|
2416
2439
|
async function startProxyRuntime(params) {
|
|
2417
2440
|
const socketWorker = isProxySocketWorkerProcess();
|
|
2418
2441
|
const { createAdaptorServer, serve } = await import("@hono/node-server");
|
|
@@ -2716,6 +2739,7 @@ async function startProxyRuntime(params) {
|
|
|
2716
2739
|
attachSocketWorkerProcess(server, {
|
|
2717
2740
|
generation: getProxyWorkerGeneration(),
|
|
2718
2741
|
version: PROXY_VERSION,
|
|
2742
|
+
processInstanceId: getProxyLifecycleLoggerSnapshot().processInstanceId,
|
|
2719
2743
|
onActivated: () => {
|
|
2720
2744
|
persistInitialProxyState();
|
|
2721
2745
|
void reconcileActivatedUpdate();
|
|
@@ -2726,6 +2750,10 @@ async function startProxyRuntime(params) {
|
|
|
2726
2750
|
});
|
|
2727
2751
|
}
|
|
2728
2752
|
}
|
|
2753
|
+
/**
|
|
2754
|
+
* Run the stable listener and journal worker incidents independently of
|
|
2755
|
+
* serving-process exits.
|
|
2756
|
+
*/
|
|
2729
2757
|
async function runLaunchdProxySupervisor(argv, spinner) {
|
|
2730
2758
|
await ensureProxyStartAllowed(spinner);
|
|
2731
2759
|
const entryScript = process.argv[1];
|
|
@@ -2736,8 +2764,20 @@ async function runLaunchdProxySupervisor(argv, spinner) {
|
|
|
2736
2764
|
const port = argv.port ?? 55669;
|
|
2737
2765
|
const workerArgs = process.argv.slice(2);
|
|
2738
2766
|
const supervisorStartedAt = new Date().toISOString();
|
|
2767
|
+
configureProxyLifecycleLogger({
|
|
2768
|
+
enabled: true,
|
|
2769
|
+
logDir: join(homedir(), ".neurolink", "logs"),
|
|
2770
|
+
filePrefix: "proxy-supervisor",
|
|
2771
|
+
});
|
|
2739
2772
|
let currentUpdaterPid;
|
|
2740
2773
|
const rollingServer = await startRollingProxyServer({
|
|
2774
|
+
onEvent: (event) => logProxyLifecycleEvent({
|
|
2775
|
+
event: "supervisor_event",
|
|
2776
|
+
requestId: "-",
|
|
2777
|
+
method: "-",
|
|
2778
|
+
path: "-",
|
|
2779
|
+
supervisorEvent: event,
|
|
2780
|
+
}),
|
|
2741
2781
|
host,
|
|
2742
2782
|
port,
|
|
2743
2783
|
initialVersion: PROXY_VERSION,
|
|
@@ -2817,6 +2857,7 @@ async function runLaunchdProxySupervisor(argv, spinner) {
|
|
|
2817
2857
|
}
|
|
2818
2858
|
logger.always(`[proxy-supervisor] listening on ${host}:${rollingServer.address.port} workerPid=${rollingServer.snapshot().active?.pid ?? "unknown"} version=${PROXY_VERSION}`);
|
|
2819
2859
|
let stopping = false;
|
|
2860
|
+
/** Drain the rolling workers and flush the supervisor journal before clearing ownership. */
|
|
2820
2861
|
const shutdown = async (signal) => {
|
|
2821
2862
|
if (stopping) {
|
|
2822
2863
|
return;
|
|
@@ -2826,6 +2867,7 @@ async function runLaunchdProxySupervisor(argv, spinner) {
|
|
|
2826
2867
|
process.off("SIGUSR2", activatePendingUpdate);
|
|
2827
2868
|
updaterSupervisor.stop();
|
|
2828
2869
|
await rollingServer.close();
|
|
2870
|
+
await flushProxyLifecycleEvents().catch((error) => logger.warn(String(error)));
|
|
2829
2871
|
const supervisorState = loadProxySupervisorState();
|
|
2830
2872
|
if (supervisorState?.pid === process.pid) {
|
|
2831
2873
|
clearProxySupervisorState();
|
|
@@ -5,6 +5,10 @@ function formatLatency(label, summary) {
|
|
|
5
5
|
const value = (amount) => amount === null ? "-" : amount.toFixed(1);
|
|
6
6
|
return `${label.padEnd(22)} ${String(summary.count).padStart(7)} ${value(summary.p50).padStart(9)} ${value(summary.p95).padStart(9)} ${value(summary.p99).padStart(9)} ${value(summary.max).padStart(9)}`;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* Render the offline evidence report without converting missing outcomes
|
|
10
|
+
* into inferred success.
|
|
11
|
+
*/
|
|
8
12
|
function printAnalysis(report) {
|
|
9
13
|
logger.always("");
|
|
10
14
|
logger.always(chalk.bold.cyan("NeuroLink Proxy Analysis"));
|
|
@@ -33,8 +37,11 @@ function printAnalysis(report) {
|
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
logger.always(report.coverage.lifecycle
|
|
36
|
-
? ` Lifecycle: ${report.lifecycle.accepted} accepted, ${report.lifecycle.terminal} terminal, ${report.lifecycle.unsettled} unsettled`
|
|
40
|
+
? ` Lifecycle: ${report.lifecycle.accepted} accepted (${report.lifecycle.auxiliaryRequests} auxiliary), ${report.lifecycle.terminal} terminal, ${report.lifecycle.unsettled} unsettled (${report.lifecycle.unconfirmedAtWorkerExit.length} unconfirmed at worker exit)`
|
|
37
41
|
: chalk.yellow(" Lifecycle: unavailable (no lifecycle metadata)"));
|
|
42
|
+
if (report.runtime.samples > 0) {
|
|
43
|
+
logger.always(` Runtime: ${report.runtime.samples} samples, max event-loop delay ${report.runtime.maxEventLoopDelayMs ?? "unknown"}ms, max CPU ${report.runtime.maxCpuPercentOneCore?.toFixed(1) ?? "unknown"}% of one core, max host load ${report.runtime.maxHostLoad1m ?? "unknown"}`);
|
|
44
|
+
}
|
|
38
45
|
if (report.coverage.attempts) {
|
|
39
46
|
const finalRateLimits = report.coverage.finalRequests
|
|
40
47
|
? `${report.requests.finalRateLimits} final`
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ProxyBodyCaptureEntry, ProcessedProxyBodyCapture } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Split redacted text into byte-bounded OTLP chunks without splitting
|
|
4
|
+
* encoded characters.
|
|
5
|
+
*/
|
|
6
|
+
export declare function splitUtf8StringByBytes(input: string, maxBytes: number): string[];
|
|
7
|
+
/** Shared pure redaction for replay; serving paths invoke it in the worker. */
|
|
8
|
+
export declare function prepareProxyBodyForLogging(body: unknown): {
|
|
9
|
+
value?: string;
|
|
10
|
+
bytes?: number;
|
|
11
|
+
truncated: boolean;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Expose the same header-redaction policy to replay and metadata
|
|
15
|
+
* consumers.
|
|
16
|
+
*/
|
|
17
|
+
export declare function redactProxyHeadersForLogging(headers: Record<string, string> | undefined): Record<string, string> | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Process a capture in the worker and retain redacted output when artifact
|
|
20
|
+
* persistence fails.
|
|
21
|
+
*/
|
|
22
|
+
export declare function processProxyBodyCapture(entry: ProxyBodyCaptureEntry, logDir: string): Promise<ProcessedProxyBodyCapture>;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { mkdir, chmod, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { gzip as gzipCallback } from "node:zlib";
|
|
6
|
+
const REQUEST_LOG_IO_TIMEOUT_MS = 5_000;
|
|
7
|
+
/** Maximum redacted body bytes persisted per capture entry. */
|
|
8
|
+
const MAX_CAPTURED_BODY_BYTES = 1024 * 1024;
|
|
9
|
+
const BODY_TRUNCATION_MARKER = "\n...[TRUNCATED]";
|
|
10
|
+
const gzip = promisify(gzipCallback);
|
|
11
|
+
/** Headers whose values must always be redacted. */
|
|
12
|
+
const SENSITIVE_HEADER_NAMES = new Set([
|
|
13
|
+
"authorization",
|
|
14
|
+
"proxy-authorization",
|
|
15
|
+
"x-api-key",
|
|
16
|
+
"cookie",
|
|
17
|
+
"set-cookie",
|
|
18
|
+
]);
|
|
19
|
+
/** Pattern that matches header names likely to contain secrets. */
|
|
20
|
+
const SENSITIVE_HEADER_PATTERN = /token|secret|key|password|credential/i;
|
|
21
|
+
/** JSON keys whose values should be redacted in request/response bodies. */
|
|
22
|
+
const SENSITIVE_BODY_KEYS = /("(?:password|access_token|refresh_token|api_key|apiKey|secret|authorization|token|credential|x-api-key)"\s*:\s*)"(?:[^"\\]|\\.)*"/gi;
|
|
23
|
+
/**
|
|
24
|
+
* Copy headers while removing credential values, including custom
|
|
25
|
+
* secret-bearing header names.
|
|
26
|
+
*/
|
|
27
|
+
function redactHeaders(headers) {
|
|
28
|
+
if (!headers) {
|
|
29
|
+
return headers;
|
|
30
|
+
}
|
|
31
|
+
const redacted = {};
|
|
32
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
33
|
+
const lower = key.toLowerCase();
|
|
34
|
+
if (SENSITIVE_HEADER_NAMES.has(lower) ||
|
|
35
|
+
SENSITIVE_HEADER_PATTERN.test(lower)) {
|
|
36
|
+
redacted[key] = "[REDACTED]";
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
redacted[key] = value;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return redacted;
|
|
43
|
+
}
|
|
44
|
+
const SENSITIVE_BODY_KEY = /^(?:password|access_token|refresh_token|api_key|apiKey|secret|authorization|token|credential|x-api-key)$/i;
|
|
45
|
+
/** Redact every value under a sensitive key, including objects and arrays. */
|
|
46
|
+
function redactBody(body) {
|
|
47
|
+
if (body === undefined || body === null) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
let value = body;
|
|
51
|
+
if (typeof body === "string") {
|
|
52
|
+
try {
|
|
53
|
+
value = JSON.parse(body);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// Non-JSON bodies (including SSE transcripts) keep the legacy fallback.
|
|
57
|
+
return body.replace(SENSITIVE_BODY_KEYS, '$1"[REDACTED]"');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return JSON.stringify(value, (key, nested) => SENSITIVE_BODY_KEY.test(key) ? "[REDACTED]" : nested);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Restrict request and phase identifiers to characters safe for artifact
|
|
64
|
+
* path components.
|
|
65
|
+
*/
|
|
66
|
+
function sanitizePhase(phase) {
|
|
67
|
+
return phase.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Hash the redacted artifact body so offline reconstruction can verify its
|
|
71
|
+
* contents.
|
|
72
|
+
*/
|
|
73
|
+
function sha256(value) {
|
|
74
|
+
return createHash("sha256").update(value).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Measure persisted UTF-8 bytes rather than JavaScript UTF-16 character
|
|
78
|
+
* counts.
|
|
79
|
+
*/
|
|
80
|
+
function utf8ByteLength(value) {
|
|
81
|
+
return Buffer.byteLength(value, "utf8");
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Reserve space for the truncation marker and cut only at a UTF-8 code
|
|
85
|
+
* point boundary.
|
|
86
|
+
*/
|
|
87
|
+
function truncateUtf8String(input, maxBytes, marker = BODY_TRUNCATION_MARKER) {
|
|
88
|
+
const inputBytes = utf8ByteLength(input);
|
|
89
|
+
if (inputBytes <= maxBytes) {
|
|
90
|
+
return { value: input, bytes: inputBytes, truncated: false };
|
|
91
|
+
}
|
|
92
|
+
const markerBytes = utf8ByteLength(marker);
|
|
93
|
+
if (maxBytes <= markerBytes) {
|
|
94
|
+
return { value: marker, bytes: markerBytes, truncated: true };
|
|
95
|
+
}
|
|
96
|
+
const buffer = Buffer.from(input, "utf8");
|
|
97
|
+
let end = maxBytes - markerBytes;
|
|
98
|
+
while (end > 0 && (buffer[end] & 0xc0) === 0x80) {
|
|
99
|
+
end -= 1;
|
|
100
|
+
}
|
|
101
|
+
const value = buffer.subarray(0, end).toString("utf8");
|
|
102
|
+
const truncatedValue = `${value}${marker}`;
|
|
103
|
+
return {
|
|
104
|
+
value: truncatedValue,
|
|
105
|
+
bytes: utf8ByteLength(truncatedValue),
|
|
106
|
+
truncated: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Split redacted text into byte-bounded OTLP chunks without splitting
|
|
111
|
+
* encoded characters.
|
|
112
|
+
*/
|
|
113
|
+
export function splitUtf8StringByBytes(input, maxBytes) {
|
|
114
|
+
if (!input) {
|
|
115
|
+
return [""];
|
|
116
|
+
}
|
|
117
|
+
const chunks = [];
|
|
118
|
+
const buffer = Buffer.from(input, "utf8");
|
|
119
|
+
for (let start = 0; start < buffer.length;) {
|
|
120
|
+
let end = Math.min(start + Math.max(4, maxBytes), buffer.length);
|
|
121
|
+
while (end < buffer.length && (buffer[end] & 0xc0) === 0x80) {
|
|
122
|
+
end -= 1;
|
|
123
|
+
}
|
|
124
|
+
chunks.push(buffer.subarray(start, end).toString("utf8"));
|
|
125
|
+
start = end;
|
|
126
|
+
}
|
|
127
|
+
return chunks;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Apply structural redaction before enforcing the per-artifact byte
|
|
131
|
+
* ceiling.
|
|
132
|
+
*/
|
|
133
|
+
function prepareRedactedBody(body) {
|
|
134
|
+
const redacted = redactBody(body);
|
|
135
|
+
if (redacted === undefined) {
|
|
136
|
+
return { truncated: false };
|
|
137
|
+
}
|
|
138
|
+
return truncateUtf8String(redacted, MAX_CAPTURED_BODY_BYTES);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Write a private gzip artifact with a unique name and return its
|
|
142
|
+
* redacted-body digest.
|
|
143
|
+
*/
|
|
144
|
+
async function writeBodyArtifact(logDir, entry, redactedHeaders, redactedBody, bodyTruncated) {
|
|
145
|
+
if (redactedBody === undefined) {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
const dateStr = new Date(entry.timestamp).toISOString().split("T")[0];
|
|
149
|
+
const bodyDir = join(logDir, "bodies", dateStr, sanitizePhase(entry.requestId));
|
|
150
|
+
await mkdir(bodyDir, { recursive: true, mode: 0o700 });
|
|
151
|
+
await chmod(bodyDir, 0o700);
|
|
152
|
+
const fileName = `${randomUUID()}-${sanitizePhase(entry.phase)}` +
|
|
153
|
+
(entry.attempt !== undefined ? `-attempt-${entry.attempt}` : "") +
|
|
154
|
+
`.json.gz`;
|
|
155
|
+
const bodyPath = join(bodyDir, fileName);
|
|
156
|
+
const payload = JSON.stringify({
|
|
157
|
+
timestamp: entry.timestamp,
|
|
158
|
+
requestId: entry.requestId,
|
|
159
|
+
phase: entry.phase,
|
|
160
|
+
model: entry.model,
|
|
161
|
+
stream: entry.stream,
|
|
162
|
+
account: entry.account,
|
|
163
|
+
accountType: entry.accountType,
|
|
164
|
+
attempt: entry.attempt,
|
|
165
|
+
responseStatus: entry.responseStatus,
|
|
166
|
+
durationMs: entry.durationMs,
|
|
167
|
+
contentType: entry.contentType,
|
|
168
|
+
headers: redactedHeaders,
|
|
169
|
+
body: redactedBody,
|
|
170
|
+
traceId: entry.traceId,
|
|
171
|
+
spanId: entry.spanId,
|
|
172
|
+
metadata: entry.metadata,
|
|
173
|
+
});
|
|
174
|
+
const compressed = await gzip(payload);
|
|
175
|
+
await writeFile(bodyPath, compressed, {
|
|
176
|
+
mode: 0o600,
|
|
177
|
+
signal: AbortSignal.timeout(REQUEST_LOG_IO_TIMEOUT_MS),
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
bodyPath,
|
|
181
|
+
bodySha256: sha256(redactedBody),
|
|
182
|
+
redactedBodyBytes: utf8ByteLength(redactedBody),
|
|
183
|
+
storedFileBytes: compressed.byteLength,
|
|
184
|
+
redactedBody,
|
|
185
|
+
bodyTruncated,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Shared pure redaction for replay; serving paths invoke it in the worker. */
|
|
189
|
+
export function prepareProxyBodyForLogging(body) {
|
|
190
|
+
return prepareRedactedBody(body);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Expose the same header-redaction policy to replay and metadata
|
|
194
|
+
* consumers.
|
|
195
|
+
*/
|
|
196
|
+
export function redactProxyHeadersForLogging(headers) {
|
|
197
|
+
return redactHeaders(headers);
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Process a capture in the worker and retain redacted output when artifact
|
|
201
|
+
* persistence fails.
|
|
202
|
+
*/
|
|
203
|
+
export async function processProxyBodyCapture(entry, logDir) {
|
|
204
|
+
const headers = redactHeaders(entry.headers);
|
|
205
|
+
const prepared = prepareRedactedBody(entry.body);
|
|
206
|
+
let stored;
|
|
207
|
+
try {
|
|
208
|
+
stored = await writeBodyArtifact(logDir, entry, headers, prepared.value, prepared.truncated);
|
|
209
|
+
}
|
|
210
|
+
catch {
|
|
211
|
+
stored = {
|
|
212
|
+
redactedBody: prepared.value,
|
|
213
|
+
redactedBodyBytes: prepared.bytes,
|
|
214
|
+
bodyTruncated: prepared.truncated,
|
|
215
|
+
bodyWriteFailed: true,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { headers, stored };
|
|
219
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ProcessedProxyBodyCapture, ProxyBodyCaptureEntry, ProxyBodyCaptureWorkerSnapshot } from "../types/index.js";
|
|
2
|
+
export declare const PROXY_BODY_CAPTURE_DEADLINE_MS = 20000;
|
|
3
|
+
/** Bounded bulk capture. Failures are indexed; never fall back to blocking work. */
|
|
4
|
+
export declare function captureProxyBody(entry: ProxyBodyCaptureEntry, logDir: string, consume: (result: ProcessedProxyBodyCapture) => Promise<void>): Promise<void>;
|
|
5
|
+
/**
|
|
6
|
+
* Return independent counters for processing, rejection, failure, and
|
|
7
|
+
* retained publication work.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getBodyCaptureWorkerSnapshot(): ProxyBodyCaptureWorkerSnapshot;
|
|
10
|
+
/** Isolated tests point at a separately executed built worker. */
|
|
11
|
+
export declare const __bodyCaptureWorkerTestHooks: {
|
|
12
|
+
/**
|
|
13
|
+
* Reset an isolated worker after capture publications drain, optionally selecting a fixture entry.
|
|
14
|
+
*/
|
|
15
|
+
reset(url?: URL): Promise<void>;
|
|
16
|
+
};
|