@juspay/neurolink 12.14.0 → 12.14.1
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 +3 -3
- package/dist/browser/neurolink.min.js +376 -376
- package/dist/cli/commands/proxy.d.ts +1 -0
- package/dist/cli/commands/proxy.js +41 -20
- package/dist/proxy/bodyCaptureProcessing.d.ts +1 -1
- package/dist/proxy/bodyCaptureProcessing.js +10 -0
- package/dist/proxy/bodyCaptureWorker.d.ts +1 -1
- package/dist/proxy/logCleanupScheduler.js +4 -0
- package/dist/proxy/otelLogSink.d.ts +34 -0
- package/dist/proxy/otelLogSink.js +254 -0
- package/dist/proxy/proxyLifecycle.js +31 -1
- package/dist/proxy/requestLogger.js +45 -10
- package/dist/proxy/workerLog.js +4 -0
- package/dist/types/proxy.d.ts +4 -0
- package/docs-site/static/search-index.json +1 -1
- package/package.json +1 -1
|
@@ -88,5 +88,6 @@ export declare const proxyStatusCommand: CommandModule<object, ProxyStatusArgs>;
|
|
|
88
88
|
export declare const proxyTelemetryCommand: CommandModule<object, ProxyTelemetryArgs>;
|
|
89
89
|
export declare const proxyGuardCommand: CommandModule<object, ProxyGuardArgs>;
|
|
90
90
|
export declare const proxySetupCommand: CommandModule;
|
|
91
|
+
export declare function buildProxyLaunchdPlist(port: number, host: string, envFile?: string, configFile?: string): string;
|
|
91
92
|
export declare const proxyInstallCommand: CommandModule;
|
|
92
93
|
export declare const proxyUninstallCommand: CommandModule;
|
|
@@ -1,14 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Implements commands for managing the Claude multi-account proxy:
|
|
5
|
-
* - neurolink proxy start — Start the proxy server
|
|
6
|
-
* - neurolink proxy status — Show proxy status (accounts, sessions, routing)
|
|
7
|
-
*
|
|
8
|
-
* The proxy creates a NeuroLink instance and builds a Hono app that registers
|
|
9
|
-
* Claude-compatible proxy routes. All requests flow through ctx.neurolink
|
|
10
|
-
* (generate/stream), with an optional ModelRouter for model remapping.
|
|
11
|
-
*/
|
|
1
|
+
import { initializeProxyOtelLogs, routeProxyConsoleToOtel, flushProxyOtelLogs, shutdownProxyOtelLogs, isProxyOtelOnly, withProxyOtelLogShutdown, } from "../../proxy/otelLogSink.js";
|
|
2
|
+
import { writeFileAtomic } from "../proxy-clients/snapshot.js";
|
|
12
3
|
import { spawn } from "node:child_process";
|
|
13
4
|
import { homedir } from "node:os";
|
|
14
5
|
import { dirname, join } from "node:path";
|
|
@@ -2398,6 +2389,8 @@ function registerProxyShutdownHandlers(params) {
|
|
|
2398
2389
|
}
|
|
2399
2390
|
try {
|
|
2400
2391
|
const { flushOpenTelemetry, shutdownOpenTelemetry } = await import("../../services/server/ai/observability/instrumentation.js");
|
|
2392
|
+
await flushProxyOtelLogs();
|
|
2393
|
+
await shutdownProxyOtelLogs();
|
|
2401
2394
|
await flushOpenTelemetry();
|
|
2402
2395
|
await shutdownOpenTelemetry();
|
|
2403
2396
|
}
|
|
@@ -2758,6 +2751,9 @@ async function startProxyRuntime(params) {
|
|
|
2758
2751
|
* serving-process exits.
|
|
2759
2752
|
*/
|
|
2760
2753
|
async function runLaunchdProxySupervisor(argv, spinner) {
|
|
2754
|
+
await loadProxyStartEnv(argv, spinner);
|
|
2755
|
+
initializeProxyOtelLogs("supervisor");
|
|
2756
|
+
routeProxyConsoleToOtel();
|
|
2761
2757
|
await ensureProxyStartAllowed(spinner);
|
|
2762
2758
|
const entryScript = process.argv[1];
|
|
2763
2759
|
if (!entryScript) {
|
|
@@ -2871,6 +2867,8 @@ async function runLaunchdProxySupervisor(argv, spinner) {
|
|
|
2871
2867
|
updaterSupervisor.stop();
|
|
2872
2868
|
await rollingServer.close();
|
|
2873
2869
|
await flushProxyLifecycleEvents().catch((error) => logger.warn(String(error)));
|
|
2870
|
+
await flushProxyOtelLogs().catch(() => undefined);
|
|
2871
|
+
await shutdownProxyOtelLogs().catch(() => undefined);
|
|
2874
2872
|
const supervisorState = loadProxySupervisorState();
|
|
2875
2873
|
if (supervisorState?.pid === process.pid) {
|
|
2876
2874
|
clearProxySupervisorState();
|
|
@@ -2951,6 +2949,8 @@ async function startProxyCommandHandler(argv) {
|
|
|
2951
2949
|
env: baseEnv,
|
|
2952
2950
|
});
|
|
2953
2951
|
const loadedEnvFile = await loadProxyStartEnv(argv, spinner);
|
|
2952
|
+
initializeProxyOtelLogs("worker");
|
|
2953
|
+
routeProxyConsoleToOtel();
|
|
2954
2954
|
// Reuse upstream TCP connections (longer keep-alive + bounded pool) instead
|
|
2955
2955
|
// of opening a new flow per request — cuts outbound flow churn through host
|
|
2956
2956
|
// content-filters. Runs once, after env load so it can be tuned via env.
|
|
@@ -3592,7 +3592,9 @@ export const proxyGuardCommand = {
|
|
|
3592
3592
|
default: true,
|
|
3593
3593
|
});
|
|
3594
3594
|
},
|
|
3595
|
-
handler: async (argv) => {
|
|
3595
|
+
handler: withProxyOtelLogShutdown(async (argv) => {
|
|
3596
|
+
initializeProxyOtelLogs("updater");
|
|
3597
|
+
routeProxyConsoleToOtel();
|
|
3596
3598
|
const host = argv.host ?? "127.0.0.1";
|
|
3597
3599
|
const port = argv.port ?? 55669;
|
|
3598
3600
|
const parentPid = Number(argv.parentPid);
|
|
@@ -3916,6 +3918,8 @@ export const proxyGuardCommand = {
|
|
|
3916
3918
|
logger.always(`[updater] update successful: now running ${result.latestVersion}`);
|
|
3917
3919
|
persistUpdaterState("record successful update", () => recordSuccessfulUpdate(result.latestVersion));
|
|
3918
3920
|
// The replacement proxy starts a worker running the new version.
|
|
3921
|
+
await flushProxyOtelLogs().catch(() => undefined);
|
|
3922
|
+
await shutdownProxyOtelLogs().catch(() => undefined);
|
|
3919
3923
|
process.exit(0);
|
|
3920
3924
|
}
|
|
3921
3925
|
else {
|
|
@@ -4083,7 +4087,7 @@ export const proxyGuardCommand = {
|
|
|
4083
4087
|
if (cleared && !argv.quiet) {
|
|
4084
4088
|
logger.always(`[proxy] fail-open guard removed stale ${expectedBaseUrl} from Claude settings`);
|
|
4085
4089
|
}
|
|
4086
|
-
},
|
|
4090
|
+
}),
|
|
4087
4091
|
};
|
|
4088
4092
|
// =============================================================================
|
|
4089
4093
|
// PROXY SETUP COMMAND
|
|
@@ -4257,7 +4261,7 @@ function buildLaunchdPath() {
|
|
|
4257
4261
|
}
|
|
4258
4262
|
return [...segments].join(":");
|
|
4259
4263
|
}
|
|
4260
|
-
function
|
|
4264
|
+
export function buildProxyLaunchdPlist(port, host, envFile, configFile) {
|
|
4261
4265
|
// The plist invokes the trampoline script (a tiny shell wrapper at
|
|
4262
4266
|
// ~/.neurolink/bin/neurolink-proxy) which re-resolves the real
|
|
4263
4267
|
// `neurolink` binary via PATH on every launch. This way, launchd
|
|
@@ -4273,6 +4277,20 @@ function buildPlist(port, host, envFile, configFile) {
|
|
|
4273
4277
|
<string>--config</string>
|
|
4274
4278
|
<string>${escapeXml(configFile)}</string>`
|
|
4275
4279
|
: "";
|
|
4280
|
+
const otelEnvironment = isProxyOtelOnly()
|
|
4281
|
+
? [
|
|
4282
|
+
"NEUROLINK_PROXY_LOG_SINK",
|
|
4283
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
|
4284
|
+
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
|
|
4285
|
+
"OTEL_EXPORTER_OTLP_HEADERS",
|
|
4286
|
+
"OTEL_EXPORTER_OTLP_LOGS_HEADERS",
|
|
4287
|
+
"OTEL_SERVICE_NAME",
|
|
4288
|
+
"NEUROLINK_PROXY_SESSION_SECRET",
|
|
4289
|
+
]
|
|
4290
|
+
.filter((name) => process.env[name] !== undefined)
|
|
4291
|
+
.map((name) => ` <key>${name}</key>\n <string>${escapeXml(process.env[name])}</string>`)
|
|
4292
|
+
.join("\n")
|
|
4293
|
+
: "";
|
|
4276
4294
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
4277
4295
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
4278
4296
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -4311,10 +4329,10 @@ ${configArgs}
|
|
|
4311
4329
|
<integer>45</integer>
|
|
4312
4330
|
|
|
4313
4331
|
<key>StandardOutPath</key>
|
|
4314
|
-
<string>${join(homedir(), ".neurolink", "logs", "proxy-launchd-stdout.log")}</string>
|
|
4332
|
+
<string>${isProxyOtelOnly() ? "/dev/null" : join(homedir(), ".neurolink", "logs", "proxy-launchd-stdout.log")}</string>
|
|
4315
4333
|
|
|
4316
4334
|
<key>StandardErrorPath</key>
|
|
4317
|
-
<string>${join(homedir(), ".neurolink", "logs", "proxy-launchd-stderr.log")}</string>
|
|
4335
|
+
<string>${isProxyOtelOnly() ? "/dev/null" : join(homedir(), ".neurolink", "logs", "proxy-launchd-stderr.log")}</string>
|
|
4318
4336
|
|
|
4319
4337
|
<key>EnvironmentVariables</key>
|
|
4320
4338
|
<dict>
|
|
@@ -4322,6 +4340,7 @@ ${configArgs}
|
|
|
4322
4340
|
<string>${buildLaunchdPath()}</string>
|
|
4323
4341
|
<key>HOME</key>
|
|
4324
4342
|
<string>${homedir()}</string>
|
|
4343
|
+
${otelEnvironment}
|
|
4325
4344
|
</dict>
|
|
4326
4345
|
</dict>
|
|
4327
4346
|
</plist>`;
|
|
@@ -4362,7 +4381,7 @@ export const proxyInstallCommand = {
|
|
|
4362
4381
|
console.info(chalk.yellow("On Linux, use systemd. On Windows, use Task Scheduler."));
|
|
4363
4382
|
process.exit(1);
|
|
4364
4383
|
}
|
|
4365
|
-
const {
|
|
4384
|
+
const { mkdirSync, existsSync, chmodSync } = await import("fs");
|
|
4366
4385
|
const envResolution = resolveProxyEnvFile({
|
|
4367
4386
|
explicitEnvFile: argv.envFile,
|
|
4368
4387
|
});
|
|
@@ -4378,8 +4397,9 @@ export const proxyInstallCommand = {
|
|
|
4378
4397
|
console.info(chalk.red(`Proxy env file not found: ${envFile}`));
|
|
4379
4398
|
process.exit(1);
|
|
4380
4399
|
}
|
|
4400
|
+
await loadProxyEnvFile({ explicitEnvFile: envFile });
|
|
4381
4401
|
const logsDir = join(homedir(), ".neurolink", "logs");
|
|
4382
|
-
if (!existsSync(logsDir)) {
|
|
4402
|
+
if (!isProxyOtelOnly() && !existsSync(logsDir)) {
|
|
4383
4403
|
mkdirSync(logsDir, { recursive: true });
|
|
4384
4404
|
}
|
|
4385
4405
|
if (!existsSync(PLIST_DIR)) {
|
|
@@ -4403,8 +4423,9 @@ export const proxyInstallCommand = {
|
|
|
4403
4423
|
process.exit(1);
|
|
4404
4424
|
}
|
|
4405
4425
|
console.info(chalk.green(`✓ Trampoline validated (resolves to neurolink v${trampolineVersion})`));
|
|
4406
|
-
const plist =
|
|
4407
|
-
|
|
4426
|
+
const plist = buildProxyLaunchdPlist(port, host, envFile, configFile);
|
|
4427
|
+
await writeFileAtomic(PLIST_PATH, plist, 0o600);
|
|
4428
|
+
chmodSync(PLIST_PATH, 0o600);
|
|
4408
4429
|
console.info(chalk.green(`✓ Plist written to ${PLIST_PATH}`));
|
|
4409
4430
|
if (envFile) {
|
|
4410
4431
|
console.info(chalk.green(`✓ Proxy env file: ${envFile}`));
|
|
@@ -19,4 +19,4 @@ export declare function redactProxyHeadersForLogging(headers: Record<string, str
|
|
|
19
19
|
* Process a capture in the worker and retain redacted output when artifact
|
|
20
20
|
* persistence fails.
|
|
21
21
|
*/
|
|
22
|
-
export declare function processProxyBodyCapture(entry: ProxyBodyCaptureEntry, logDir: string): Promise<ProcessedProxyBodyCapture>;
|
|
22
|
+
export declare function processProxyBodyCapture(entry: ProxyBodyCaptureEntry, logDir: string | null): Promise<ProcessedProxyBodyCapture>;
|
|
@@ -203,6 +203,16 @@ export function redactProxyHeadersForLogging(headers) {
|
|
|
203
203
|
export async function processProxyBodyCapture(entry, logDir) {
|
|
204
204
|
const headers = redactHeaders(entry.headers);
|
|
205
205
|
const prepared = prepareRedactedBody(entry.body);
|
|
206
|
+
if (logDir === null) {
|
|
207
|
+
return {
|
|
208
|
+
headers,
|
|
209
|
+
stored: {
|
|
210
|
+
redactedBody: prepared.value,
|
|
211
|
+
redactedBodyBytes: prepared.bytes,
|
|
212
|
+
bodyTruncated: prepared.truncated,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
206
216
|
let stored;
|
|
207
217
|
try {
|
|
208
218
|
stored = await writeBodyArtifact(logDir, entry, headers, prepared.value, prepared.truncated);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ProcessedProxyBodyCapture, ProxyBodyCaptureEntry, ProxyBodyCaptureWorkerSnapshot } from "../types/index.js";
|
|
2
2
|
export declare const PROXY_BODY_CAPTURE_DEADLINE_MS = 20000;
|
|
3
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>;
|
|
4
|
+
export declare function captureProxyBody(entry: ProxyBodyCaptureEntry, logDir: string | null, consume: (result: ProcessedProxyBodyCapture) => Promise<void>): Promise<void>;
|
|
5
5
|
/**
|
|
6
6
|
* Return independent counters for processing, rejection, failure, and
|
|
7
7
|
* retained publication work.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isProxyOtelOnly } from "./otelLogSink.js";
|
|
1
2
|
import { Worker } from "node:worker_threads";
|
|
2
3
|
import { withTimeout } from "../utils/async/withTimeout.js";
|
|
3
4
|
import { logger } from "../utils/logger.js";
|
|
@@ -9,6 +10,9 @@ const WORKER_TERMINATION_TIMEOUT_MS = 5_000;
|
|
|
9
10
|
* block active streams. Concurrent runs are coalesced into the active scan.
|
|
10
11
|
*/
|
|
11
12
|
export function startProxyLogCleanupScheduler(params) {
|
|
13
|
+
if (isProxyOtelOnly()) {
|
|
14
|
+
return { trigger: () => false, stop: async () => { } };
|
|
15
|
+
}
|
|
12
16
|
const maxAgeDays = params.maxAgeDays ?? 7;
|
|
13
17
|
const maxSizeMb = params.maxSizeMb ?? 500;
|
|
14
18
|
let activeWorker;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { LoggerProvider } from "@opentelemetry/sdk-logs";
|
|
2
|
+
/** Explicit opt-in; configuration never silently falls back to file logging. */
|
|
3
|
+
export declare function isProxyOtelOnly(): boolean;
|
|
4
|
+
/** Initialize a log-only provider in every proxy process, including the supervisor. */
|
|
5
|
+
export declare function initializeProxyOtelLogs(role?: string): LoggerProvider | undefined;
|
|
6
|
+
/** Structured evidence without final-request dashboard fields on auxiliary events. */
|
|
7
|
+
export declare function emitProxyOtelEvent(kind: string, record: Record<string, unknown>): void;
|
|
8
|
+
/** Capture application console diagnostics only inside proxy service processes. */
|
|
9
|
+
export declare function routeProxyConsoleToOtel(): void;
|
|
10
|
+
/** Counters acknowledge collector transport only, never backend persistence. */
|
|
11
|
+
export declare function getProxyOtelLogSnapshot(): {
|
|
12
|
+
mode: string;
|
|
13
|
+
initialized: boolean;
|
|
14
|
+
deliveryGuarantee: string;
|
|
15
|
+
invalidRecords: number;
|
|
16
|
+
queues: {
|
|
17
|
+
attempted: number;
|
|
18
|
+
submitted: number;
|
|
19
|
+
transportAcknowledged: number;
|
|
20
|
+
exportUnconfirmed: number;
|
|
21
|
+
dropped: number;
|
|
22
|
+
outstanding: number;
|
|
23
|
+
lastAcknowledgedAt: string | undefined;
|
|
24
|
+
lastFailureAt: string | undefined;
|
|
25
|
+
kind: string;
|
|
26
|
+
capacity: number;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
/** Bounded provider flush belongs after final request and lifecycle publication. */
|
|
30
|
+
export declare function flushProxyOtelLogs(): Promise<void>;
|
|
31
|
+
/** Release this process's exporter and restore console ownership. */
|
|
32
|
+
export declare function shutdownProxyOtelLogs(): Promise<void>;
|
|
33
|
+
/** Flush short-lived proxy command diagnostics on every normal return or exception. */
|
|
34
|
+
export declare function withProxyOtelLogShutdown<TArg>(handler: (arg: TArg) => Promise<void>): (arg: TArg) => Promise<void>;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/* eslint-disable no-console -- This proxy-only sink replaces console methods with OTLP emission. */
|
|
2
|
+
import { inspect } from "node:util";
|
|
3
|
+
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
4
|
+
import { ExportResultCode } from "@opentelemetry/core";
|
|
5
|
+
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
|
6
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
7
|
+
import { BatchLogRecordProcessor, LoggerProvider, } from "@opentelemetry/sdk-logs";
|
|
8
|
+
import { sanitizeForLog } from "../utils/logSanitize.js";
|
|
9
|
+
let provider;
|
|
10
|
+
let restoreConsole;
|
|
11
|
+
const queues = [];
|
|
12
|
+
/** Explicit opt-in; configuration never silently falls back to file logging. */
|
|
13
|
+
export function isProxyOtelOnly() {
|
|
14
|
+
return process.env.NEUROLINK_PROXY_LOG_SINK === "otel";
|
|
15
|
+
}
|
|
16
|
+
/** Reserve capacity including exports in flight, independently for metadata and bodies. */
|
|
17
|
+
function createTrackedProcessor(url, capacity) {
|
|
18
|
+
const state = {
|
|
19
|
+
attempted: 0,
|
|
20
|
+
submitted: 0,
|
|
21
|
+
transportAcknowledged: 0,
|
|
22
|
+
exportUnconfirmed: 0,
|
|
23
|
+
dropped: 0,
|
|
24
|
+
outstanding: 0,
|
|
25
|
+
lastAcknowledgedAt: undefined,
|
|
26
|
+
lastFailureAt: undefined,
|
|
27
|
+
};
|
|
28
|
+
const transport = new OTLPLogExporter({ url, timeoutMillis: 5000 });
|
|
29
|
+
const exporter = {
|
|
30
|
+
export(records, callback) {
|
|
31
|
+
let settled = false;
|
|
32
|
+
const settle = (result) => {
|
|
33
|
+
if (settled) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
settled = true;
|
|
37
|
+
state.outstanding -= records.length;
|
|
38
|
+
if (result.code === ExportResultCode.SUCCESS) {
|
|
39
|
+
state.transportAcknowledged += records.length;
|
|
40
|
+
state.lastAcknowledgedAt = new Date().toISOString();
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
state.exportUnconfirmed += records.length;
|
|
44
|
+
state.lastFailureAt = new Date().toISOString();
|
|
45
|
+
}
|
|
46
|
+
callback(result);
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
transport.export(records, settle);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
settle({
|
|
53
|
+
code: ExportResultCode.FAILED,
|
|
54
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
shutdown: () => transport.shutdown(),
|
|
59
|
+
};
|
|
60
|
+
const batch = new BatchLogRecordProcessor(exporter, {
|
|
61
|
+
maxQueueSize: capacity,
|
|
62
|
+
maxExportBatchSize: 64,
|
|
63
|
+
scheduledDelayMillis: 1000,
|
|
64
|
+
exportTimeoutMillis: 6000,
|
|
65
|
+
});
|
|
66
|
+
const processor = {
|
|
67
|
+
onEmit(record) {
|
|
68
|
+
state.attempted++;
|
|
69
|
+
if (state.outstanding >= capacity) {
|
|
70
|
+
state.dropped++;
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
state.submitted++;
|
|
74
|
+
state.outstanding++;
|
|
75
|
+
batch.onEmit(record);
|
|
76
|
+
},
|
|
77
|
+
forceFlush: () => batch.forceFlush(),
|
|
78
|
+
shutdown: () => batch.shutdown(),
|
|
79
|
+
};
|
|
80
|
+
return { state, processor, capacity };
|
|
81
|
+
}
|
|
82
|
+
/** Initialize a log-only provider in every proxy process, including the supervisor. */
|
|
83
|
+
export function initializeProxyOtelLogs(role = "worker") {
|
|
84
|
+
if (!isProxyOtelOnly()) {
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
if (provider) {
|
|
88
|
+
return provider;
|
|
89
|
+
}
|
|
90
|
+
const endpoint = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ??
|
|
91
|
+
(process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
92
|
+
? `${process.env.OTEL_EXPORTER_OTLP_ENDPOINT.replace(/\/$/, "")}/v1/logs`
|
|
93
|
+
: undefined);
|
|
94
|
+
if (!endpoint) {
|
|
95
|
+
throw new Error("OTel-only proxy logging requires an OTLP endpoint");
|
|
96
|
+
}
|
|
97
|
+
const url = new URL(endpoint);
|
|
98
|
+
if (!["http:", "https:"].includes(url.protocol)) {
|
|
99
|
+
throw new Error("Proxy OTLP logs endpoint must use HTTP or HTTPS");
|
|
100
|
+
}
|
|
101
|
+
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
102
|
+
if (url.protocol === "http:" && !loopback) {
|
|
103
|
+
throw new Error("Proxy OTLP logs require HTTPS for non-loopback collectors");
|
|
104
|
+
}
|
|
105
|
+
const metadata = createTrackedProcessor(endpoint, 2048);
|
|
106
|
+
const bodies = createTrackedProcessor(endpoint, 256);
|
|
107
|
+
queues.push(metadata, bodies);
|
|
108
|
+
provider = new LoggerProvider({
|
|
109
|
+
resource: resourceFromAttributes({
|
|
110
|
+
"service.name": process.env.OTEL_SERVICE_NAME ?? "neurolink-proxy",
|
|
111
|
+
"service.instance.id": `${role}-${process.pid}`,
|
|
112
|
+
"process.pid": process.pid,
|
|
113
|
+
"proxy.process.role": role,
|
|
114
|
+
}),
|
|
115
|
+
processors: [
|
|
116
|
+
{
|
|
117
|
+
onEmit(record, context) {
|
|
118
|
+
(record.attributes?.["proxy.record_kind"] === "body"
|
|
119
|
+
? bodies
|
|
120
|
+
: metadata).processor.onEmit(record, context);
|
|
121
|
+
},
|
|
122
|
+
forceFlush: async () => {
|
|
123
|
+
await Promise.all(queues.map((q) => q.processor.forceFlush()));
|
|
124
|
+
},
|
|
125
|
+
shutdown: async () => {
|
|
126
|
+
await Promise.all(queues.map((q) => q.processor.shutdown()));
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
});
|
|
131
|
+
return provider;
|
|
132
|
+
}
|
|
133
|
+
/** Structured evidence without final-request dashboard fields on auxiliary events. */
|
|
134
|
+
export function emitProxyOtelEvent(kind, record) {
|
|
135
|
+
if (!isProxyOtelOnly()) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
initializeProxyOtelLogs()
|
|
140
|
+
?.getLogger("neurolink-proxy-events")
|
|
141
|
+
.emit({
|
|
142
|
+
severityNumber: SeverityNumber.INFO,
|
|
143
|
+
severityText: "INFO",
|
|
144
|
+
body: JSON.stringify(record),
|
|
145
|
+
attributes: {
|
|
146
|
+
"proxy.record_kind": kind,
|
|
147
|
+
"event.name": `proxy.${kind}`,
|
|
148
|
+
...(typeof record.requestId === "string"
|
|
149
|
+
? { "request.id": record.requestId }
|
|
150
|
+
: {}),
|
|
151
|
+
...(typeof record.event === "string"
|
|
152
|
+
? { "proxy.lifecycle.event": record.event }
|
|
153
|
+
: {}),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
// Telemetry must never fail a model request. Invalid records are observable.
|
|
159
|
+
invalidRecords++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
let invalidRecords = 0;
|
|
163
|
+
/** Capture application console diagnostics only inside proxy service processes. */
|
|
164
|
+
export function routeProxyConsoleToOtel() {
|
|
165
|
+
if (!isProxyOtelOnly() || restoreConsole) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
initializeProxyOtelLogs();
|
|
169
|
+
const originals = {
|
|
170
|
+
log: console.log,
|
|
171
|
+
info: console.info,
|
|
172
|
+
warn: console.warn,
|
|
173
|
+
error: console.error,
|
|
174
|
+
debug: console.debug,
|
|
175
|
+
};
|
|
176
|
+
let emitting = false;
|
|
177
|
+
for (const level of Object.keys(originals)) {
|
|
178
|
+
console[level] = (...args) => {
|
|
179
|
+
if (emitting) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
emitting = true;
|
|
183
|
+
try {
|
|
184
|
+
const body = args
|
|
185
|
+
.map((value) => typeof value === "string"
|
|
186
|
+
? value
|
|
187
|
+
: inspect(value, {
|
|
188
|
+
depth: 4,
|
|
189
|
+
maxArrayLength: 30,
|
|
190
|
+
maxStringLength: 16000,
|
|
191
|
+
}))
|
|
192
|
+
.join(" ");
|
|
193
|
+
provider?.getLogger("neurolink-proxy-console").emit({
|
|
194
|
+
body: sanitizeForLog(body, 32000),
|
|
195
|
+
severityText: level.toUpperCase(),
|
|
196
|
+
severityNumber: level === "error"
|
|
197
|
+
? SeverityNumber.ERROR
|
|
198
|
+
: level === "warn"
|
|
199
|
+
? SeverityNumber.WARN
|
|
200
|
+
: level === "debug"
|
|
201
|
+
? SeverityNumber.DEBUG
|
|
202
|
+
: SeverityNumber.INFO,
|
|
203
|
+
attributes: { "proxy.record_kind": "console" },
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
invalidRecords++;
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
emitting = false;
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
restoreConsole = () => Object.assign(console, originals);
|
|
215
|
+
}
|
|
216
|
+
/** Counters acknowledge collector transport only, never backend persistence. */
|
|
217
|
+
export function getProxyOtelLogSnapshot() {
|
|
218
|
+
return {
|
|
219
|
+
mode: isProxyOtelOnly() ? "otel" : "file-and-otel",
|
|
220
|
+
initialized: provider !== undefined,
|
|
221
|
+
deliveryGuarantee: "best-effort; HTTP success is not per-record acceptance or backend persistence",
|
|
222
|
+
invalidRecords,
|
|
223
|
+
queues: queues.map((q, index) => ({
|
|
224
|
+
kind: index === 0 ? "metadata" : "bodies",
|
|
225
|
+
capacity: q.capacity,
|
|
226
|
+
...q.state,
|
|
227
|
+
})),
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
/** Bounded provider flush belongs after final request and lifecycle publication. */
|
|
231
|
+
export async function flushProxyOtelLogs() {
|
|
232
|
+
await provider?.forceFlush();
|
|
233
|
+
}
|
|
234
|
+
/** Release this process's exporter and restore console ownership. */
|
|
235
|
+
export async function shutdownProxyOtelLogs() {
|
|
236
|
+
restoreConsole?.();
|
|
237
|
+
restoreConsole = undefined;
|
|
238
|
+
await provider?.shutdown();
|
|
239
|
+
provider = undefined;
|
|
240
|
+
queues.length = 0;
|
|
241
|
+
invalidRecords = 0;
|
|
242
|
+
}
|
|
243
|
+
/** Flush short-lived proxy command diagnostics on every normal return or exception. */
|
|
244
|
+
export function withProxyOtelLogShutdown(handler) {
|
|
245
|
+
return async (arg) => {
|
|
246
|
+
try {
|
|
247
|
+
await handler(arg);
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await flushProxyOtelLogs().catch(() => undefined);
|
|
251
|
+
await shutdownProxyOtelLogs().catch(() => undefined);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { emitProxyOtelEvent, isProxyOtelOnly, initializeProxyOtelLogs, } from "./otelLogSink.js";
|
|
1
2
|
import { createHash, createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
2
3
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { appendFile } from "node:fs/promises";
|
|
@@ -297,6 +298,25 @@ export function configureProxyLifecycleLogger(options) {
|
|
|
297
298
|
batchSize = positiveInteger(options.batchSize, DEFAULT_BATCH_SIZE);
|
|
298
299
|
maxWriteRetries = positiveInteger(options.maxWriteRetries, DEFAULT_MAX_WRITE_RETRIES);
|
|
299
300
|
flushIntervalMs = positiveInteger(options.flushIntervalMs, DEFAULT_FLUSH_INTERVAL_MS);
|
|
301
|
+
if (options.enabled && isProxyOtelOnly()) {
|
|
302
|
+
initializeProxyOtelLogs(options.filePrefix === "proxy-supervisor" ? "supervisor" : "worker");
|
|
303
|
+
loggerEnabled = true;
|
|
304
|
+
sessionHashKey = process.env.NEUROLINK_PROXY_SESSION_SECRET
|
|
305
|
+
? createHash("sha256")
|
|
306
|
+
.update(process.env.NEUROLINK_PROXY_SESSION_SECRET)
|
|
307
|
+
.digest()
|
|
308
|
+
: sessionHashKey;
|
|
309
|
+
stopRuntimeMetrics = startProxyRuntimeMetrics((runtimeSample) => {
|
|
310
|
+
logProxyLifecycleEvent({
|
|
311
|
+
event: "runtime_sample",
|
|
312
|
+
requestId: "-",
|
|
313
|
+
method: "-",
|
|
314
|
+
path: "-",
|
|
315
|
+
runtimeSample,
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
300
320
|
if (options.enabled && options.logDir) {
|
|
301
321
|
try {
|
|
302
322
|
mkdirSync(options.logDir, { recursive: true, mode: 0o700 });
|
|
@@ -336,7 +356,7 @@ export function logProxyLifecycleEvent(input) {
|
|
|
336
356
|
* capacity is unavailable.
|
|
337
357
|
*/
|
|
338
358
|
function enqueueLifecycleEvent(input, onPersisted) {
|
|
339
|
-
if (!loggerEnabled || !lifecycleLogDir) {
|
|
359
|
+
if (!loggerEnabled || (!lifecycleLogDir && !isProxyOtelOnly())) {
|
|
340
360
|
onPersisted?.(false);
|
|
341
361
|
return;
|
|
342
362
|
}
|
|
@@ -406,6 +426,10 @@ function enqueueLifecycleEvent(input, onPersisted) {
|
|
|
406
426
|
: {}),
|
|
407
427
|
...(input.runtimeSample ? { runtimeSample: input.runtimeSample } : {}),
|
|
408
428
|
};
|
|
429
|
+
if (isProxyOtelOnly()) {
|
|
430
|
+
emitProxyOtelEvent(filePrefix === "proxy-supervisor" ? "supervisor" : "lifecycle", record);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
409
433
|
queue.push({
|
|
410
434
|
filePrefix,
|
|
411
435
|
logDir: lifecycleLogDir,
|
|
@@ -431,6 +455,10 @@ export async function persistProxyLifecycleAcceptance(input, timeoutMs = LIFECYC
|
|
|
431
455
|
if (!loggerRequired) {
|
|
432
456
|
return;
|
|
433
457
|
}
|
|
458
|
+
if (isProxyOtelOnly()) {
|
|
459
|
+
enqueueLifecycleEvent({ ...input, event: "request_accepted" });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
434
462
|
const confirmed = new Promise((resolve) => {
|
|
435
463
|
enqueueLifecycleEvent({ ...input, event: "request_accepted" }, resolve);
|
|
436
464
|
});
|
|
@@ -462,6 +490,8 @@ export async function flushProxyLifecycleEvents(timeoutMs = 5_000) {
|
|
|
462
490
|
export function getProxyLifecycleLoggerSnapshot() {
|
|
463
491
|
return {
|
|
464
492
|
enabled: loggerEnabled,
|
|
493
|
+
sink: isProxyOtelOnly() ? "otel" : "file",
|
|
494
|
+
admissionPolicy: isProxyOtelOnly() ? "best-effort" : "durable-file",
|
|
465
495
|
schemaVersion: SCHEMA_VERSION,
|
|
466
496
|
processInstanceId,
|
|
467
497
|
nextSequence,
|