@juspay/neurolink 12.13.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 +52 -29
- package/dist/cli/proxy-clients/grok.d.ts +45 -0
- package/dist/cli/proxy-clients/grok.js +342 -0
- package/dist/cli/proxy-clients/registry.d.ts +2 -2
- package/dist/cli/proxy-clients/registry.js +4 -2
- package/dist/constants/proxyModels.d.ts +7 -1
- package/dist/constants/proxyModels.js +7 -1
- 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/clientAttribution.js +3 -0
- 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/proxyConfig.d.ts +4 -0
- package/dist/proxy/proxyConfig.js +13 -1
- 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/dist/types/proxyClient.d.ts +34 -1
- package/docs-site/static/search-index.json +1 -1
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Model IDs the proxy advertises when no routing config narrows the list.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three consumers need the same answer and must not drift apart:
|
|
5
5
|
*
|
|
6
6
|
* - `proxyTranslationEngine` serves them from `GET /v1/models`.
|
|
7
7
|
* - The OpenCode client configurator writes them into `provider.neurolink.
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* It does not call `/v1/models`, so an empty map means every model id is
|
|
10
10
|
* unknown and `opencode run` fails with `ProviderModelNotFoundError`
|
|
11
11
|
* before a request is ever made.
|
|
12
|
+
* - The Grok Build configurator writes the same ids (plus any
|
|
13
|
+
* `routing.model-mappings` in the active proxy config — default
|
|
14
|
+
* `~/.neurolink/proxy-config.yaml`, or the path passed to `--config`)
|
|
15
|
+
* into `~/.grok/config.toml` as `[model.<id>]` entries, with
|
|
16
|
+
* `api_backend` and `context_window` so Grok's compaction matches the
|
|
17
|
+
* upstream. Built-in grok-* ids are skipped.
|
|
12
18
|
*
|
|
13
19
|
* Format matches the IDs used throughout `src/lib/models/` and
|
|
14
20
|
* `src/lib/constants/` (e.g. `claude-3-5-haiku-20241022`, not
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Model IDs the proxy advertises when no routing config narrows the list.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Three consumers need the same answer and must not drift apart:
|
|
5
5
|
*
|
|
6
6
|
* - `proxyTranslationEngine` serves them from `GET /v1/models`.
|
|
7
7
|
* - The OpenCode client configurator writes them into `provider.neurolink.
|
|
@@ -9,6 +9,12 @@
|
|
|
9
9
|
* It does not call `/v1/models`, so an empty map means every model id is
|
|
10
10
|
* unknown and `opencode run` fails with `ProviderModelNotFoundError`
|
|
11
11
|
* before a request is ever made.
|
|
12
|
+
* - The Grok Build configurator writes the same ids (plus any
|
|
13
|
+
* `routing.model-mappings` in the active proxy config — default
|
|
14
|
+
* `~/.neurolink/proxy-config.yaml`, or the path passed to `--config`)
|
|
15
|
+
* into `~/.grok/config.toml` as `[model.<id>]` entries, with
|
|
16
|
+
* `api_backend` and `context_window` so Grok's compaction matches the
|
|
17
|
+
* upstream. Built-in grok-* ids are skipped.
|
|
12
18
|
*
|
|
13
19
|
* Format matches the IDs used throughout `src/lib/models/` and
|
|
14
20
|
* `src/lib/constants/` (e.g. `claude-3-5-haiku-20241022`, not
|
|
@@ -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.
|
|
@@ -39,6 +39,9 @@ const CLIENT_PREFIXES = [
|
|
|
39
39
|
// different token, and it is left unmapped until someone measures it rather
|
|
40
40
|
// than pattern-matched on a guess.
|
|
41
41
|
["codex_exec/", "codex"],
|
|
42
|
+
// "grok-shell/1.0.30 (macos; aarch64)" — measured 2026-09-12 on
|
|
43
|
+
// POST /v1/messages from Grok Build 1.0.30.
|
|
44
|
+
["grok-shell/", "grok"],
|
|
42
45
|
];
|
|
43
46
|
/**
|
|
44
47
|
* Deliberately NOT mapped, and why each was ruled out.
|
|
@@ -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
|
+
}
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
* falls back to JSON.parse.
|
|
12
12
|
*/
|
|
13
13
|
import type { LoadProxyConfigOptions, ProxyConfigFile } from "../types/index.js";
|
|
14
|
+
/** Default on-disk path used when `proxy start` is not given `--config`. */
|
|
15
|
+
export declare function defaultProxyConfigPath(): string;
|
|
16
|
+
/** Resolve `--config` the same way `proxy start` / `proxy install` do. */
|
|
17
|
+
export declare function resolveProxyConfigPath(explicit?: string): string;
|
|
14
18
|
/**
|
|
15
19
|
* Replace all `${VAR}` / `${VAR:-default}` references in a string.
|
|
16
20
|
*
|
|
@@ -11,9 +11,21 @@
|
|
|
11
11
|
* falls back to JSON.parse.
|
|
12
12
|
*/
|
|
13
13
|
import { readFile } from "node:fs/promises";
|
|
14
|
-
import {
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { extname, join, resolve } from "node:path";
|
|
15
16
|
import { MAX_MAX_INFLIGHT_PER_ACCOUNT, MIN_MAX_INFLIGHT_PER_ACCOUNT, } from "./modelRouter.js";
|
|
16
17
|
import { logger } from "../utils/logger.js";
|
|
18
|
+
/** Default on-disk path used when `proxy start` is not given `--config`. */
|
|
19
|
+
export function defaultProxyConfigPath() {
|
|
20
|
+
return join(homedir(), ".neurolink", "proxy-config.yaml");
|
|
21
|
+
}
|
|
22
|
+
/** Resolve `--config` the same way `proxy start` / `proxy install` do. */
|
|
23
|
+
export function resolveProxyConfigPath(explicit) {
|
|
24
|
+
const trimmed = explicit?.trim();
|
|
25
|
+
return trimmed && trimmed.length > 0
|
|
26
|
+
? resolve(trimmed)
|
|
27
|
+
: defaultProxyConfigPath();
|
|
28
|
+
}
|
|
17
29
|
// ---------------------------------------------------------------------------
|
|
18
30
|
// Environment variable resolution
|
|
19
31
|
// ---------------------------------------------------------------------------
|
|
@@ -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,
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* when a LoggerProvider is configured via OpenTelemetry instrumentation.
|
|
6
6
|
* Useful for debugging and auditing proxy traffic.
|
|
7
7
|
*/
|
|
8
|
+
import { emitProxyOtelEvent, getProxyOtelLogSnapshot, initializeProxyOtelLogs, isProxyOtelOnly, } from "./otelLogSink.js";
|
|
8
9
|
import { join } from "path";
|
|
9
10
|
import { homedir } from "os";
|
|
10
11
|
import { logger } from "../utils/logger.js";
|
|
@@ -46,6 +47,8 @@ const metadataSinks = {
|
|
|
46
47
|
export function getRequestLoggerSnapshot() {
|
|
47
48
|
return {
|
|
48
49
|
enabled: logEnabled,
|
|
50
|
+
diskEnabled: logEnabled && !isProxyOtelOnly(),
|
|
51
|
+
otel: getProxyOtelLogSnapshot(),
|
|
49
52
|
requests: { ...metadataSinks.requests },
|
|
50
53
|
attempts: { ...metadataSinks.attempts },
|
|
51
54
|
debug: { ...metadataSinks.debug },
|
|
@@ -155,6 +158,12 @@ export function initRequestLogger(enabled = true, customLogsDir) {
|
|
|
155
158
|
configureProxyLifecycleLogger({ enabled: false });
|
|
156
159
|
return;
|
|
157
160
|
}
|
|
161
|
+
if (isProxyOtelOnly()) {
|
|
162
|
+
initializeProxyOtelLogs();
|
|
163
|
+
logDir = null;
|
|
164
|
+
configureProxyLifecycleLogger({ enabled: true });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
158
167
|
try {
|
|
159
168
|
logDir = customLogsDir ?? join(homedir(), ".neurolink", "logs");
|
|
160
169
|
if (!existsSync(logDir)) {
|
|
@@ -183,7 +192,7 @@ export async function logRequest(entry) {
|
|
|
183
192
|
? "handler_error"
|
|
184
193
|
: "completed";
|
|
185
194
|
notifyProxyFinalLog(entry);
|
|
186
|
-
if (!logEnabled || !logDir) {
|
|
195
|
+
if (!logEnabled || (!logDir && !isProxyOtelOnly())) {
|
|
187
196
|
return;
|
|
188
197
|
}
|
|
189
198
|
// Only use OtelBridge if traceId not already provided by caller.
|
|
@@ -197,6 +206,10 @@ export async function logRequest(entry) {
|
|
|
197
206
|
entry.spanId = traceCtx.spanId;
|
|
198
207
|
}
|
|
199
208
|
}
|
|
209
|
+
if (isProxyOtelOnly()) {
|
|
210
|
+
await emitOtlpLogRecord(entry);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
200
213
|
const logFile = join(logDir, `proxy-${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
201
214
|
const line = JSON.stringify(entry) + "\n";
|
|
202
215
|
try {
|
|
@@ -206,7 +219,7 @@ export async function logRequest(entry) {
|
|
|
206
219
|
// Non-fatal — don't crash proxy for logging failures
|
|
207
220
|
}
|
|
208
221
|
// Emit OTLP log record (additive — file logging is the primary sink)
|
|
209
|
-
emitOtlpLogRecord(entry);
|
|
222
|
+
void emitOtlpLogRecord(entry);
|
|
210
223
|
}
|
|
211
224
|
/**
|
|
212
225
|
* Log an upstream attempt separately from the final request outcome.
|
|
@@ -214,7 +227,7 @@ export async function logRequest(entry) {
|
|
|
214
227
|
* or OTLP-derived dashboard panels.
|
|
215
228
|
*/
|
|
216
229
|
export async function logRequestAttempt(entry) {
|
|
217
|
-
if (!logEnabled || !logDir) {
|
|
230
|
+
if (!logEnabled || (!logDir && !isProxyOtelOnly())) {
|
|
218
231
|
return;
|
|
219
232
|
}
|
|
220
233
|
if (!entry.traceId) {
|
|
@@ -225,6 +238,10 @@ export async function logRequestAttempt(entry) {
|
|
|
225
238
|
entry.spanId = traceCtx.spanId;
|
|
226
239
|
}
|
|
227
240
|
}
|
|
241
|
+
if (isProxyOtelOnly()) {
|
|
242
|
+
emitProxyOtelEvent("attempt", entry);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
228
245
|
const logFile = join(logDir, `proxy-attempts-${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
229
246
|
const line = JSON.stringify(entry) + "\n";
|
|
230
247
|
try {
|
|
@@ -242,6 +259,9 @@ export async function logRequestAttempt(entry) {
|
|
|
242
259
|
* where OTel initialization completes after the first log request.
|
|
243
260
|
*/
|
|
244
261
|
async function resolveLoggerProvider() {
|
|
262
|
+
if (isProxyOtelOnly()) {
|
|
263
|
+
return initializeProxyOtelLogs();
|
|
264
|
+
}
|
|
245
265
|
if (otelLoggerProvider === false) {
|
|
246
266
|
return undefined;
|
|
247
267
|
} // permanently unavailable
|
|
@@ -275,7 +295,7 @@ async function resolveLoggerProvider() {
|
|
|
275
295
|
* Non-blocking, non-fatal — failures are silently swallowed.
|
|
276
296
|
*/
|
|
277
297
|
function emitOtlpLogRecord(entry) {
|
|
278
|
-
resolveLoggerProvider()
|
|
298
|
+
return resolveLoggerProvider()
|
|
279
299
|
.then((provider) => {
|
|
280
300
|
if (!provider) {
|
|
281
301
|
return;
|
|
@@ -293,8 +313,11 @@ function emitOtlpLogRecord(entry) {
|
|
|
293
313
|
otelLogger.emit({
|
|
294
314
|
severityNumber,
|
|
295
315
|
severityText,
|
|
296
|
-
body:
|
|
316
|
+
body: isProxyOtelOnly()
|
|
317
|
+
? JSON.stringify(entry)
|
|
318
|
+
: `${entry.method} ${entry.path} → ${entry.responseStatus} (${entry.responseTimeMs}ms)`,
|
|
297
319
|
attributes: {
|
|
320
|
+
"proxy.record_kind": "request_final",
|
|
298
321
|
// Core request fields
|
|
299
322
|
"request.id": entry.requestId,
|
|
300
323
|
"http.method": entry.method,
|
|
@@ -440,6 +463,7 @@ function emitOtlpBodyLogRecord(entry, stored) {
|
|
|
440
463
|
body: chunk,
|
|
441
464
|
attributes: {
|
|
442
465
|
"event.name": "proxy.body_capture",
|
|
466
|
+
"proxy.record_kind": "body",
|
|
443
467
|
"request.id": entry.requestId,
|
|
444
468
|
"body.phase": entry.phase,
|
|
445
469
|
"body.chunk_index": chunkIndex,
|
|
@@ -484,7 +508,7 @@ function emitOtlpBodyLogRecord(entry, stored) {
|
|
|
484
508
|
}
|
|
485
509
|
/** Capture an owned request body with bounded processing and tracked index/export publication. */
|
|
486
510
|
export async function logBodyCapture(entry) {
|
|
487
|
-
if (!logEnabled || !logDir) {
|
|
511
|
+
if (!logEnabled || (!logDir && !isProxyOtelOnly())) {
|
|
488
512
|
return;
|
|
489
513
|
}
|
|
490
514
|
// Borrowed traffic is somebody else's conversation. Capturing it would leave
|
|
@@ -507,7 +531,9 @@ export async function logBodyCapture(entry) {
|
|
|
507
531
|
const redactedHeaders = processed.headers;
|
|
508
532
|
const stored = processed.stored;
|
|
509
533
|
const dateStr = new Date(metadata.timestamp).toISOString().split("T")[0];
|
|
510
|
-
const logFile =
|
|
534
|
+
const logFile = destination
|
|
535
|
+
? join(destination, `proxy-debug-${dateStr}.jsonl`)
|
|
536
|
+
: undefined;
|
|
511
537
|
const indexEntry = {
|
|
512
538
|
timestamp: metadata.timestamp,
|
|
513
539
|
type: "body_capture",
|
|
@@ -538,8 +564,13 @@ export async function logBodyCapture(entry) {
|
|
|
538
564
|
indexEntry.traceId = traceCtx.traceId;
|
|
539
565
|
indexEntry.spanId = traceCtx.spanId;
|
|
540
566
|
}
|
|
567
|
+
if (isProxyOtelOnly()) {
|
|
568
|
+
emitProxyOtelEvent("body_capture_index", indexEntry);
|
|
569
|
+
}
|
|
541
570
|
try {
|
|
542
|
-
|
|
571
|
+
if (logFile) {
|
|
572
|
+
await appendMetadataRecord(logFile, JSON.stringify(indexEntry) + "\n", "debug", { waitForPersistence: true });
|
|
573
|
+
}
|
|
543
574
|
}
|
|
544
575
|
catch {
|
|
545
576
|
// Non-fatal
|
|
@@ -596,12 +627,11 @@ export async function logFullRequestResponse(entry) {
|
|
|
596
627
|
* These are invisible in normal request logs since the 200 was already recorded.
|
|
597
628
|
*/
|
|
598
629
|
export async function logStreamError(entry) {
|
|
599
|
-
if (!logEnabled || !logDir) {
|
|
630
|
+
if (!logEnabled || (!logDir && !isProxyOtelOnly())) {
|
|
600
631
|
return;
|
|
601
632
|
}
|
|
602
633
|
const bridge = new OtelBridge();
|
|
603
634
|
const traceCtx = bridge.getCurrentTraceContext();
|
|
604
|
-
const logFile = join(logDir, `proxy-${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
605
635
|
const logEntry = {
|
|
606
636
|
...entry,
|
|
607
637
|
responseStatus: 200,
|
|
@@ -614,6 +644,11 @@ export async function logStreamError(entry) {
|
|
|
614
644
|
logEntry.traceId = traceCtx.traceId;
|
|
615
645
|
logEntry.spanId = traceCtx.spanId;
|
|
616
646
|
}
|
|
647
|
+
if (isProxyOtelOnly()) {
|
|
648
|
+
emitProxyOtelEvent("stream_error", logEntry);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const logFile = join(logDir, `proxy-${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
617
652
|
try {
|
|
618
653
|
await appendMetadataRecord(logFile, JSON.stringify(logEntry) + "\n", "requests");
|
|
619
654
|
}
|