@juspay/neurolink 12.14.0 → 12.14.2
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 +4 -3
- package/dist/browser/neurolink.min.js +366 -366
- package/dist/cli/commands/proxy.d.ts +1 -0
- package/dist/cli/commands/proxy.js +41 -20
- package/dist/neurolink.js +16 -0
- package/dist/providers/anthropic/client.js +73 -4
- 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/aiCompat.d.ts +1 -0
- package/dist/types/proxy.d.ts +4 -0
- package/docs-site/static/search-index.json +1 -1
- package/package.json +6 -5
|
@@ -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}`));
|
package/dist/neurolink.js
CHANGED
|
@@ -4159,6 +4159,22 @@ Current user's request: ${currentInput}`;
|
|
|
4159
4159
|
middleware: options.middleware,
|
|
4160
4160
|
conversationMessages: options.conversationMessages,
|
|
4161
4161
|
credentials: options.credentials,
|
|
4162
|
+
// Extended thinking. Every provider gates it on `thinkingConfig`, so
|
|
4163
|
+
// omitting the field here meant it never reached one: the request went
|
|
4164
|
+
// out with no thinking block and the result carried no reasoning, with
|
|
4165
|
+
// nothing raised to say the option had been discarded. This is the same
|
|
4166
|
+
// failure the note above records for `disableInternalFallback` — an
|
|
4167
|
+
// allowlist that silently swallows a documented option.
|
|
4168
|
+
//
|
|
4169
|
+
// `thinkingConfig` is the only one of the documented thinking options
|
|
4170
|
+
// that `GenerateOptions` actually declares. `thinking`, `thinkingBudget`
|
|
4171
|
+
// and `thinkingLevel` are folded into a `thinkingConfig` only by the CLI,
|
|
4172
|
+
// in `src/lib/utils/thinkingConfig.ts`; nothing on the SDK path does that
|
|
4173
|
+
// merge. They exist solely on the internal `TextGenerationOptions`, so no
|
|
4174
|
+
// caller can pass them through
|
|
4175
|
+
// generate() today. Declaring them is a public-type decision and is
|
|
4176
|
+
// deliberately left out of this fix.
|
|
4177
|
+
thinkingConfig: options.thinkingConfig,
|
|
4162
4178
|
// Lifecycle callbacks must reach the provider so non-AI-SDK paths
|
|
4163
4179
|
// (Vertex's native @google/genai, native Bedrock, Ollama, etc.) can
|
|
4164
4180
|
// invoke them directly. Pipeline A also still receives them via the
|
|
@@ -292,6 +292,12 @@ const messagesToAnthropic = (msgs) => {
|
|
|
292
292
|
}
|
|
293
293
|
case "assistant": {
|
|
294
294
|
const blocks = [];
|
|
295
|
+
// Extended thinking must come back byte-identical — signature
|
|
296
|
+
// included — or Anthropic rejects the turn, and the loop replays this
|
|
297
|
+
// message on every tool step. Blocks are emitted in content order
|
|
298
|
+
// rather than hoisted: `interleaved-thinking-2025-05-14` (requested in
|
|
299
|
+
// the beta header) lets thinking appear between tool calls, so
|
|
300
|
+
// reordering would corrupt the chain it validates.
|
|
295
301
|
for (const part of partsOf(msg.content)) {
|
|
296
302
|
if (typeof part === "string") {
|
|
297
303
|
if (part.length > 0) {
|
|
@@ -300,6 +306,29 @@ const messagesToAnthropic = (msgs) => {
|
|
|
300
306
|
continue;
|
|
301
307
|
}
|
|
302
308
|
const p = part;
|
|
309
|
+
if (p?.type === "reasoning") {
|
|
310
|
+
const meta = p.providerOptions?.anthropic;
|
|
311
|
+
const redacted = meta?.redactedData;
|
|
312
|
+
if (typeof redacted === "string" && redacted.length > 0) {
|
|
313
|
+
blocks.push({ type: "redacted_thinking", data: redacted });
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const signature = meta?.signature;
|
|
317
|
+
// Both halves required, matching loopAdapter's check on the
|
|
318
|
+
// streaming path: Anthropic rejects a thinking block that is
|
|
319
|
+
// unsigned, and equally one whose text is empty. Reasoning from a
|
|
320
|
+
// provider that never produced a signature (a reasoner model's
|
|
321
|
+
// plain text) is not an Anthropic thinking block at all, and an
|
|
322
|
+
// empty one carries nothing worth replaying — either way, dropping
|
|
323
|
+
// it beats sending a block that will be refused.
|
|
324
|
+
if (typeof signature === "string" &&
|
|
325
|
+
signature.length > 0 &&
|
|
326
|
+
typeof p.text === "string" &&
|
|
327
|
+
p.text.length > 0) {
|
|
328
|
+
blocks.push({ type: "thinking", thinking: p.text, signature });
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
303
332
|
if (p?.type === "text" && typeof p.text === "string") {
|
|
304
333
|
if (p.text.length > 0) {
|
|
305
334
|
const cc = cacheControlOf(p);
|
|
@@ -1118,15 +1147,28 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1118
1147
|
? { topP: options.topP }
|
|
1119
1148
|
: {}),
|
|
1120
1149
|
}, "anthropic.doGenerate");
|
|
1150
|
+
// Dropping a caller's explicit sampling parameters is exactly the
|
|
1151
|
+
// kind of silent discard this change fixes elsewhere, so say so.
|
|
1152
|
+
if (thinking &&
|
|
1153
|
+
(samplingParams.temperature !== undefined ||
|
|
1154
|
+
samplingParams.topP !== undefined)) {
|
|
1155
|
+
logger.debug("[anthropic] extended thinking is enabled, so temperature/top_p are omitted — Anthropic rejects any temperature but 1 while thinking is set");
|
|
1156
|
+
}
|
|
1121
1157
|
const params = {
|
|
1122
1158
|
model: modelId,
|
|
1123
1159
|
messages: cachedMessages,
|
|
1124
1160
|
max_tokens: resolveClaudeMaxTokens(modelId, options.maxOutputTokens),
|
|
1125
1161
|
...(system ? { system } : {}),
|
|
1126
|
-
|
|
1162
|
+
// Extended thinking fixes sampling: Anthropic rejects any
|
|
1163
|
+
// temperature but 1 while `thinking` is set, and does not honour
|
|
1164
|
+
// top_p there. The CLI always sends a default temperature, so
|
|
1165
|
+
// forwarding it alongside thinking turns a call that used to work
|
|
1166
|
+
// into a 400. Drop the sampling knobs for exactly those turns and
|
|
1167
|
+
// let Anthropic's thinking defaults stand.
|
|
1168
|
+
...(!thinking && samplingParams.temperature !== undefined
|
|
1127
1169
|
? { temperature: samplingParams.temperature }
|
|
1128
1170
|
: {}),
|
|
1129
|
-
...(samplingParams.topP !== undefined
|
|
1171
|
+
...(!thinking && samplingParams.topP !== undefined
|
|
1130
1172
|
? { top_p: samplingParams.topP }
|
|
1131
1173
|
: {}),
|
|
1132
1174
|
...(options.stopSequences && options.stopSequences.length > 0
|
|
@@ -1187,7 +1229,29 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1187
1229
|
let jsonToolAnswered = false;
|
|
1188
1230
|
for (const block of response.content) {
|
|
1189
1231
|
if (block.type === "thinking") {
|
|
1190
|
-
|
|
1232
|
+
// The signature rides along in providerOptions because Anthropic
|
|
1233
|
+
// rejects a replayed thinking block without it, and the tool loop
|
|
1234
|
+
// pushes this part straight back into the conversation.
|
|
1235
|
+
content.push({
|
|
1236
|
+
type: "reasoning",
|
|
1237
|
+
text: block.thinking,
|
|
1238
|
+
providerOptions: {
|
|
1239
|
+
anthropic: { signature: block.signature },
|
|
1240
|
+
},
|
|
1241
|
+
});
|
|
1242
|
+
}
|
|
1243
|
+
else if (block.type === "redacted_thinking") {
|
|
1244
|
+
// Encrypted reasoning: no readable text, but it must still be
|
|
1245
|
+
// replayed verbatim or the turn is rejected.
|
|
1246
|
+
content.push({
|
|
1247
|
+
type: "reasoning",
|
|
1248
|
+
text: "",
|
|
1249
|
+
providerOptions: {
|
|
1250
|
+
anthropic: {
|
|
1251
|
+
redactedData: block.data,
|
|
1252
|
+
},
|
|
1253
|
+
},
|
|
1254
|
+
});
|
|
1191
1255
|
}
|
|
1192
1256
|
else if (block.type === "text") {
|
|
1193
1257
|
// In forced-json mode the payload arrives via the tool input, not
|
|
@@ -1824,6 +1888,9 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1824
1888
|
const streamSamplingParams = resolveSamplingParams("anthropic", modelId, options.temperature !== undefined && options.temperature !== null
|
|
1825
1889
|
? { temperature: options.temperature }
|
|
1826
1890
|
: {}, "anthropic.executeStream");
|
|
1891
|
+
if (thinking && streamSamplingParams.temperature !== undefined) {
|
|
1892
|
+
logger.debug("[anthropic] extended thinking is enabled, so temperature is omitted on the stream path — Anthropic rejects any temperature but 1 while thinking is set");
|
|
1893
|
+
}
|
|
1827
1894
|
return {
|
|
1828
1895
|
model: modelId,
|
|
1829
1896
|
messages: cachedConversation,
|
|
@@ -1833,7 +1900,9 @@ export class AnthropicProvider extends BaseProvider {
|
|
|
1833
1900
|
// the adapter immediately overwrites — and forced this whole params
|
|
1834
1901
|
// object into the streaming variant for a field it does not own.
|
|
1835
1902
|
...(payload.system ? { system: payload.system } : {}),
|
|
1836
|
-
|
|
1903
|
+
// Same constraint on the streaming path: a temperature alongside
|
|
1904
|
+
// `thinking` is rejected outright.
|
|
1905
|
+
...(!thinking && streamSamplingParams.temperature !== undefined
|
|
1837
1906
|
? { temperature: streamSamplingParams.temperature }
|
|
1838
1907
|
: {}),
|
|
1839
1908
|
...(cachedTools && cachedTools.length > 0
|
|
@@ -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
|
+
}
|