@juspay/neurolink
Version:
The pipe layer of an AI nervous system: one interface connecting provider neurons to your application, across three inference types — generate, stream and decide. `decide` returns typed, calibrated judgements from a non-generative model (~400ms, ~$0.00002
946 lines (945 loc) • 39.8 kB
JavaScript
/**
* Proxy Request Logger
* Logs proxy request/response metadata to a rotating log file.
* Also emits OTLP log records to OpenObserve (or any OTLP-compatible backend)
* when a LoggerProvider is configured via OpenTelemetry instrumentation.
* Useful for debugging and auditing proxy traffic.
*/
import { emitProxyOtelEvent, getProxyOtelLogSnapshot, initializeProxyOtelLogs, isProxyOtelOnly, publishProxyOtelBody, } from "./otelLogSink.js";
import { randomUUID } from "node:crypto";
import { createProxyBodyCapturePolicy } from "./bodyCapturePolicy.js";
import { join } from "path";
import { homedir } from "os";
import { logger } from "../utils/logger.js";
import { calculateCost, hasPricing, isExactPricingMatch, } from "../utils/pricing.js";
import { proxyTokenUsage } from "./proxyTokenUsage.js";
import { chmodSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, unlinkSync, } from "fs";
import { writeFile } from "fs/promises";
import { setImmediate as yieldToRequests } from "node:timers/promises";
import { captureProxyBody, getBodyCaptureWorkerSnapshot, PROXY_BODY_CAPTURE_DEADLINE_MS, } from "./bodyCaptureWorker.js";
import { prepareProxyBodyForLogging as prepareRedactedBody, redactProxyHeadersForLogging as redactHeaders, splitUtf8StringByBytes, } from "./bodyCaptureProcessing.js";
import { isBorrowedRequest } from "./shareContext.js";
import { OtelBridge } from "../observability/otelBridge.js";
import { createRequire } from "node:module";
/**
* Loaded via require() on first actual use rather than a top-level import,
* so requiring this module (reachable eagerly from dist/server/index.js)
* doesn't pull in @opentelemetry/api-logs for callers who never emit an
* OTLP log record. Both use sites below only run once resolveLoggerProvider()
* has already resolved a real provider, so OTel logging is already active.
*/
const otelRequire = createRequire(import.meta.url);
let SeverityNumber;
let severityNumberLoaded = false;
function loadSeverityNumber() {
if (severityNumberLoaded) {
return;
}
({ SeverityNumber } = otelRequire("@opentelemetry/api-logs"));
severityNumberLoaded = true;
}
import { configureProxyLifecycleLogger } from "./proxyLifecycle.js";
import { notifyProxyFinalLog, notifyProxyAttemptLog, getProxyRequestAccounting, } from "./proxyActivity.js";
import { withTimeout } from "../utils/async/withTimeout.js";
import { resolveProxyLogTraceContext, proxyLogContext, } from "./proxyTraceContext.js";
let logDir = null;
let logEnabled = false;
let bodyCapturePolicy = createProxyBodyCapturePolicy();
const pendingLogOperations = new Set();
const REQUEST_LOG_IO_TIMEOUT_MS = 5_000;
// launchd gives the service 45 seconds after SIGTERM. Leave fifteen seconds
// for listener drain and exporter shutdown instead of advertising a flush
// deadline the service manager can terminate before it reaches.
const REQUEST_LOG_FLUSH_TIMEOUT_MS = PROXY_BODY_CAPTURE_DEADLINE_MS + 2 * REQUEST_LOG_IO_TIMEOUT_MS;
const MAX_PENDING_METADATA_RECORDS = 4_096;
const appendChains = new Map();
let appendMetadataFile = writeFile;
const createSinkSnapshot = () => ({
attempted: 0,
written: 0,
inFlight: 0,
pending: 0,
dropped: 0,
writeTimeouts: 0,
unconfirmedWrites: 0,
});
const metadataSinks = {
requests: createSinkSnapshot(),
attempts: createSinkSnapshot(),
debug: createSinkSnapshot(),
};
/**
* Expose independent metadata-sink and body-capture counters for incident reconciliation.
*/
export function getRequestLoggerSnapshot() {
return {
enabled: logEnabled,
diskEnabled: logEnabled && !isProxyOtelOnly(),
otel: getProxyOtelLogSnapshot(),
requests: { ...metadataSinks.requests },
attempts: { ...metadataSinks.attempts },
debug: { ...metadataSinks.debug },
bodyCapture: getBodyCaptureWorkerSnapshot(),
bodyCapturePolicy: bodyCapturePolicy.snapshot(),
};
}
/**
* Serialize append ownership; capture callers retain their memory lease until the write settles.
*/
async function appendMetadataRecord(file, line, kind, options = {}) {
const sink = metadataSinks[kind];
sink.attempted += 1;
if (sink.pending + sink.inFlight >= MAX_PENDING_METADATA_RECORDS) {
sink.dropped += 1;
return;
}
sink.pending += 1;
// writeFile may perform multiple append syscalls for a large record. Order
// them per destination so concurrent records cannot interleave in this worker.
const operation = trackLogOperation((appendChains.get(file) ?? Promise.resolve()).then(async () => {
sink.pending -= 1;
sink.inFlight += 1;
const timer = setTimeout(() => {
sink.writeTimeouts += 1;
}, REQUEST_LOG_IO_TIMEOUT_MS);
timer.unref?.();
try {
await appendMetadataFile(file, line, { mode: 0o600, flag: "a" });
sink.written += 1;
}
catch (error) {
// A failed append may have written a prefix; never replay it.
sink.unconfirmedWrites += 1;
sink.lastErrorCode =
error?.code ?? "UNKNOWN";
}
finally {
clearTimeout(timer);
sink.inFlight -= 1;
}
}));
appendChains.set(file, operation);
void operation.then(() => {
if (appendChains.get(file) === operation) {
appendChains.delete(file);
}
});
if (options.waitForPersistence) {
// Bulk capture owns a memory lease until publication settles. A caller
// timeout must not release that lease while the serialized index is queued.
return operation;
}
// Bound the caller's wait, not the lifetime/ownership of the underlying write.
await withTimeout(operation, REQUEST_LOG_IO_TIMEOUT_MS, "Proxy metadata write remains pending").catch(() => undefined);
}
/**
* Retain asynchronous log ownership until settlement so shutdown can await admitted publication.
*/
function trackLogOperation(operation) {
pendingLogOperations.add(operation);
void operation.then(() => pendingLogOperations.delete(operation), () => pendingLogOperations.delete(operation));
return operation;
}
/**
* Wait through bounded OTel admission, worker processing, and publication
* deadlines so a clean shutdown does not abandon an accepted overflow capture.
*/
export async function flushRequestLogs(timeoutMs = REQUEST_LOG_FLUSH_TIMEOUT_MS) {
const deadline = Date.now() + Math.max(1, timeoutMs);
while (pendingLogOperations.size > 0) {
const admitted = [...pendingLogOperations];
const remainingMs = Math.max(1, deadline - Date.now());
await withTimeout(Promise.allSettled(admitted), remainingMs, `Timed out flushing ${admitted.length} proxy request log operation(s)`);
if (Date.now() >= deadline && pendingLogOperations.size > 0) {
const remaining = pendingLogOperations.size;
throw new Error(`Timed out flushing ${remaining} proxy request log operation(s)`);
}
}
}
/** @internal Test-only hook for exercising shutdown behavior without real I/O. */
export const __requestLoggerTestHooks = {
defaultFlushTimeoutMs: REQUEST_LOG_FLUSH_TIMEOUT_MS,
pendingOperationCount: () => pendingLogOperations.size,
trackLogOperation,
/**
* Pricing annotation, exposed because it is otherwise reachable only through
* a full log-write cycle. What it refuses to price matters as much as what it
* prices: a turn whose cached portion is unknown must not be charged as if
* the cache were empty.
*/
annotateRequestPricing,
setAppendFileForTests: (writer) => {
appendMetadataFile = writer;
},
restoreAppendFileForTests: () => {
appendMetadataFile = writeFile;
},
};
/**
* Lazily-resolved LoggerProvider from OTel instrumentation.
* null = not resolved yet (will retry), LoggerProvider = resolved, false = permanently unavailable.
*/
let otelLoggerProvider = null;
/** Number of times we've tried to resolve the LoggerProvider. */
let otelResolveAttempts = 0;
/** Max number of resolve attempts before giving up. */
const MAX_RESOLVE_ATTEMPTS = 10;
const BODY_OTLP_CHUNK_SIZE = 16_000;
/**
* Initialize private request logs and preserve required lifecycle admission on startup failures.
*/
export function initRequestLogger(enabled = true, customLogsDir) {
// Lifecycle metadata deliberately shares the request logger's enablement,
// directory permissions, retention boundary, and operator privacy control.
logEnabled = enabled;
bodyCapturePolicy = createProxyBodyCapturePolicy({
bytesPerMinute: process.env.NEUROLINK_PROXY_BODY_BYTES_PER_MINUTE,
deduplicate: process.env.NEUROLINK_PROXY_BODY_DEDUPLICATION !== "false",
});
if (!enabled) {
configureProxyLifecycleLogger({ enabled: false });
return;
}
if (isProxyOtelOnly()) {
initializeProxyOtelLogs();
logDir = null;
configureProxyLifecycleLogger({ enabled: true });
return;
}
try {
logDir = customLogsDir ?? join(homedir(), ".neurolink", "logs");
if (!existsSync(logDir)) {
mkdirSync(logDir, { recursive: true, mode: 0o700 });
}
chmodSync(logDir, 0o700);
configureProxyLifecycleLogger({ enabled: true, logDir });
}
catch (err) {
logEnabled = false;
logDir = null;
configureProxyLifecycleLogger({
enabled: true,
logDir: customLogsDir ?? join(homedir(), ".neurolink", "logs"),
});
logger.warn(`[proxy] Request logging disabled — failed to create log directory: ${err instanceof Error ? err.message : String(err)}`);
}
}
/** Keep price-table estimates distinct from actual subscription billing. */
function annotateRequestPricing(entry) {
entry.apiEquivalentCostUsd = null;
entry.apiEquivalentCacheSavingsUsd = null;
entry.pricingBasis = "api_price_table";
entry.pricingProvider = entry.provider ?? "openai-compatible";
if (entry.usageOwnerRequestId &&
entry.usageOwnerRequestId !== entry.requestId) {
entry.pricingStatus = "owned_by_child";
return;
}
const input = entry.inputTokens;
const output = entry.outputTokens;
const cacheRead = entry.cacheReadTokens ?? 0;
const cacheCreation = entry.cacheCreationTokens ?? 0;
const cacheCreation1h = entry.cacheCreation1hTokens;
if (input === undefined ||
output === undefined ||
!Number.isFinite(input) ||
input < 0 ||
!Number.isFinite(output) ||
output < 0 ||
!Number.isFinite(cacheRead) ||
cacheRead < 0 ||
!Number.isFinite(cacheCreation) ||
cacheCreation < 0 ||
// The 1h count is a subset of the cache-write total.
(cacheCreation1h !== undefined &&
(!Number.isFinite(cacheCreation1h) ||
cacheCreation1h < 0 ||
cacheCreation1h > cacheCreation)) ||
(entry.inputIncludesCachedTokens === true &&
cacheRead + cacheCreation > input) ||
// When input includes the cached portion but the provider never reported
// it, subtracting a defaulted 0 prices the cached tokens at full input
// rate and reports zero cache savings. That is a guess, not a price, so
// the turn is incomplete rather than silently mispriced.
(entry.inputIncludesCachedTokens === true &&
(entry.cacheReadTokensObserved === false ||
entry.cacheCreationTokensObserved === false))) {
entry.pricingStatus = "usage_incomplete";
return;
}
if (!hasPricing(entry.pricingProvider, entry.model)) {
entry.pricingStatus = "unavailable";
return;
}
if (!isExactPricingMatch(entry.pricingProvider, entry.model)) {
entry.pricingStatus = "inferred";
return;
}
const usage = proxyTokenUsage({
inputTokens: input,
outputTokens: output,
cacheReadTokens: cacheRead,
cacheCreationTokens: cacheCreation,
inputIncludesCachedTokens: entry.inputIncludesCachedTokens,
...(entry.cacheCreation1hTokens !== undefined
? { cacheCreation1hTokens: entry.cacheCreation1hTokens }
: {}),
});
entry.pricingStatus = "exact";
entry.apiEquivalentCostUsd = calculateCost(entry.pricingProvider, entry.model, usage);
const uncachedCost = calculateCost(entry.pricingProvider, entry.model, {
...usage,
input: usage.input + (usage.cacheReadTokens ?? 0),
cacheReadTokens: 0,
});
entry.apiEquivalentCacheSavingsUsd = Math.max(0, Math.round((uncachedCost - entry.apiEquivalentCostUsd) * 1_000_000) /
1_000_000);
}
export async function logRequest(entry) {
Object.assign(entry, getProxyRequestAccounting(entry.requestId));
annotateRequestPricing(entry);
if (!entry.traceId || entry.traceFlags === undefined) {
const traceCtx = resolveProxyLogTraceContext(entry);
if (traceCtx) {
Object.assign(entry, traceCtx);
}
}
entry.terminalOutcome ??=
entry.errorType === "client_cancelled" || entry.responseStatus === 499
? "client_cancelled"
: entry.errorType?.includes("stream")
? "stream_error"
: entry.responseStatus >= 400 || entry.errorType
? "handler_error"
: "completed";
if (!notifyProxyFinalLog(entry)) {
return;
}
if (!logEnabled || (!logDir && !isProxyOtelOnly())) {
return;
}
if (isProxyOtelOnly()) {
await emitOtlpLogRecord(entry);
return;
}
const logFile = join(logDir, `proxy-${new Date().toISOString().split("T")[0]}.jsonl`);
const line = JSON.stringify(entry) + "\n";
try {
await appendMetadataRecord(logFile, line, "requests");
}
catch {
// Non-fatal — don't crash proxy for logging failures
}
// Emit OTLP log record (additive — file logging is the primary sink)
void emitOtlpLogRecord(entry);
}
/**
* Log an upstream attempt separately from the final request outcome.
* Attempt logs are local-only and must not pollute the final request summary
* or OTLP-derived dashboard panels.
*/
export async function logRequestAttempt(entry) {
if (!entry.traceId || entry.traceFlags === undefined) {
const traceCtx = resolveProxyLogTraceContext(entry);
if (traceCtx) {
entry.traceId = traceCtx.traceId;
entry.spanId = traceCtx.spanId;
entry.traceFlags = traceCtx.traceFlags;
}
}
Object.assign(entry, getProxyRequestAccounting(entry.requestId));
notifyProxyAttemptLog(entry);
if (!logEnabled || (!logDir && !isProxyOtelOnly())) {
return;
}
if (isProxyOtelOnly()) {
emitProxyOtelEvent("attempt", entry);
return;
}
const logFile = join(logDir, `proxy-attempts-${new Date().toISOString().split("T")[0]}.jsonl`);
const line = JSON.stringify(entry) + "\n";
try {
await appendMetadataRecord(logFile, line, "attempts");
}
catch {
// Non-fatal — don't crash proxy for logging failures
}
}
/**
* Lazily resolve the LoggerProvider from OTel instrumentation.
* Uses dynamic import to avoid hard dependency — if instrumentation.ts
* hasn't been loaded or OTLP is not configured, this is a no-op.
* Retries up to MAX_RESOLVE_ATTEMPTS times to handle race conditions
* where OTel initialization completes after the first log request.
*/
async function resolveLoggerProvider() {
if (isProxyOtelOnly()) {
return initializeProxyOtelLogs();
}
if (otelLoggerProvider === false) {
return undefined;
} // permanently unavailable
if (otelLoggerProvider !== null) {
return otelLoggerProvider;
}
// Not resolved yet — try to resolve
otelResolveAttempts++;
try {
const { getLoggerProvider } = await import("../services/server/ai/observability/instrumentation.js");
const provider = getLoggerProvider();
if (provider) {
otelLoggerProvider = provider;
return provider;
}
// Provider not available yet — if we've exceeded max attempts, give up
if (otelResolveAttempts >= MAX_RESOLVE_ATTEMPTS) {
otelLoggerProvider = false; // permanently unavailable
}
// Otherwise leave as null so we retry next time
return undefined;
}
catch {
// instrumentation.ts not available (e.g. standalone mode) — disable permanently
otelLoggerProvider = false;
return undefined;
}
}
/**
* Emit a RequestLogEntry as an OTLP log record.
* Non-blocking, non-fatal — failures are silently swallowed.
*/
function emitOtlpLogRecord(entry) {
return resolveLoggerProvider()
.then((provider) => {
if (!provider) {
return;
}
loadSeverityNumber();
const otelLogger = provider.getLogger("neurolink-proxy", "1.0.0");
// A streaming response can fail after sending HTTP 200. Indexed fields
// and severity must agree with the final semantic outcome in the body.
const isError = (entry.responseStatus ?? 0) >= 400 ||
Boolean(entry.errorType) ||
entry.terminalOutcome === "stream_error" ||
entry.terminalOutcome === "handler_error" ||
entry.terminalOutcome === "client_cancelled";
const isRateLimit = entry.responseStatus === 429;
const severityNumber = isError
? isRateLimit
? SeverityNumber.WARN
: SeverityNumber.ERROR
: SeverityNumber.INFO;
const severityText = isError ? (isRateLimit ? "WARN" : "ERROR") : "INFO";
otelLogger.emit({
context: proxyLogContext(entry),
severityNumber,
severityText,
body: isProxyOtelOnly()
? JSON.stringify(entry)
: `${entry.method} ${entry.path} → ${entry.responseStatus} (${entry.responseTimeMs}ms)`,
attributes: {
"proxy.record_kind": "request_final",
"proxy.accounting_scope": entry.accountingScope ?? "client",
...(entry.parentRequestId
? { "proxy.parent_request_id": entry.parentRequestId }
: {}),
...(entry.usageOwnerRequestId
? { "proxy.usage_owner_request_id": entry.usageOwnerRequestId }
: {}),
...(entry.inputIncludesCachedTokens !== undefined
? {
"ai.input_includes_cached_tokens": entry.inputIncludesCachedTokens,
}
: {}),
// Core request fields
"request.id": entry.requestId,
"http.method": entry.method,
"http.path": entry.path,
"http.status_code": entry.responseStatus,
"response.time_ms": entry.responseTimeMs,
// AI-specific fields
"ai.model": entry.model,
"ai.stream": entry.stream,
"ai.tool_count": entry.toolCount,
"ai.pricing.status": entry.pricingStatus,
"ai.pricing.basis": entry.pricingBasis,
"ai.pricing.provider": entry.pricingProvider,
...(entry.apiEquivalentCostUsd !== null &&
entry.apiEquivalentCostUsd !== undefined
? { "ai.cost.api_equivalent_usd": entry.apiEquivalentCostUsd }
: {}),
...(entry.apiEquivalentCacheSavingsUsd !== null &&
entry.apiEquivalentCacheSavingsUsd !== undefined
? {
"ai.cost.cache_savings_usd": entry.apiEquivalentCacheSavingsUsd,
}
: {}),
// Account info
"account.name": entry.account,
"account.type": entry.accountType,
// Compact routing summary. Full candidate evidence stays in JSONL.
...(entry.routingDecision && {
"routing.mode": entry.routingDecision.mode,
"routing.strategy": entry.routingDecision.strategy,
"routing.selection_reason": entry.routingDecision.selectionReason,
"routing.initial_account": entry.routingDecision.initialAccount,
"routing.candidate_count": entry.routingDecision.candidates.length,
"routing.final_account_changed": entry.routingDecision.initialAccount !== entry.account &&
entry.routingDecision.candidates.some((candidate) => candidate.account === entry.account &&
candidate.accountType === entry.accountType),
}),
// Token usage (when available)
...(entry.inputTokens !== undefined && {
"ai.input_tokens": entry.inputTokens,
}),
...(entry.outputTokens !== undefined && {
"ai.output_tokens": entry.outputTokens,
}),
...(entry.cacheCreationTokens !== undefined && {
"ai.cache_creation_tokens": entry.cacheCreationTokens,
}),
...(entry.cacheReadTokens !== undefined && {
"ai.cache_read_tokens": entry.cacheReadTokens,
}),
// Error info (when present)
...(entry.errorType && { "error.type": entry.errorType }),
...(entry.errorMessage && { "error.message": entry.errorMessage }),
// Trace correlation
...(entry.traceId && { "trace.id": entry.traceId }),
...(entry.spanId && { "span.id": entry.spanId }),
// Derived fields for dashboards (matches backfill script)
is_success: !isError &&
entry.responseStatus >= 200 &&
entry.responseStatus < 400,
is_rate_limited: entry.responseStatus === 429,
is_overloaded: entry.responseStatus === 529,
is_error: isError,
source: "otlp",
},
});
})
.catch(() => {
// Non-fatal — never crash proxy for OTLP log failures
});
}
export function getLogDir() {
return logDir;
}
/**
* Redact sensitive header values in-place.
*/
export function redactProxyHeadersForLogging(headers) {
return redactHeaders(headers);
}
/** Return a redacted body representation suitable for persisted request diagnostics. */
export function prepareProxyBodyForLogging(body) {
return prepareRedactedBody(body);
}
/** Enumerate recognized proxy journals and body artifacts for retention accounting. */
function collectManagedLogFiles(rootDir) {
const managedFiles = [];
/** Collect file sizes and modification times while descending the log directory. */
const walk = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const entryPath = join(directory, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
continue;
}
const isTopLevelProxyLog = directory === rootDir &&
/^proxy(?:-attempts|-debug|-lifecycle|-supervisor)?-.*\.jsonl$/.test(entry.name);
const isBodyArtifact = entry.name.endsWith(".json.gz") &&
entryPath.includes(`${join(rootDir, "bodies")}`);
if (!isTopLevelProxyLog && !isBodyArtifact) {
continue;
}
try {
const stat = statSync(entryPath);
managedFiles.push({
path: entryPath,
mtime: stat.mtimeMs,
size: stat.size,
});
}
catch {
// Non-fatal
}
}
};
walk(rootDir);
return managedFiles;
}
function pruneEmptyDirectories(directory, stopAt) {
if (!existsSync(directory)) {
return;
}
try {
const entries = readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
pruneEmptyDirectories(join(directory, entry.name), stopAt);
}
}
if (directory !== stopAt && readdirSync(directory).length === 0) {
rmSync(directory, { recursive: true, force: true });
}
}
catch {
// Non-fatal
}
}
/** Publish redacted UTF-8 chunks, yielding between groups to keep requests responsive. */
function emitOtlpBodyLogRecord(entry, stored) {
return resolveLoggerProvider()
.then(async (provider) => {
if (!provider || stored.redactedBody === undefined) {
return undefined;
}
loadSeverityNumber();
const otelLogger = provider.getLogger("neurolink-proxy-bodies", "1.0.0");
const captureId = entry.captureId ?? randomUUID();
const emit = (chunk, chunkIndex, totalChunks) => {
otelLogger.emit({
context: proxyLogContext(entry),
severityNumber: (entry.responseStatus ?? 0) >= 400
? SeverityNumber.WARN
: SeverityNumber.INFO,
severityText: (entry.responseStatus ?? 0) >= 400 ? "WARN" : "INFO",
body: chunk,
attributes: {
"event.name": "proxy.body_capture",
"proxy.record_kind": "body",
"request.id": entry.requestId,
"body.phase": entry.phase,
"body.capture_id": captureId,
"body.chunk_index": chunkIndex,
"body.chunk_count": totalChunks,
"body.content_type": entry.contentType ?? "application/json",
"ai.model": entry.model,
"ai.stream": entry.stream,
...(entry.account && { "account.name": entry.account }),
...(entry.accountType && { "account.type": entry.accountType }),
...(entry.attempt !== undefined && {
"proxy.attempt": entry.attempt,
}),
...(entry.responseStatus !== undefined && {
"http.status_code": entry.responseStatus,
}),
...(entry.durationMs !== undefined && {
"response.time_ms": entry.durationMs,
}),
...(stored.bodySha256 && { "body.sha256": stored.bodySha256 }),
...(stored.bodyPath && {
"body.path": stored.bodyPath.split("/").slice(-2).join("/"),
}),
...(stored.redactedBodyBytes !== undefined && {
"body.bytes": stored.redactedBodyBytes,
}),
...(stored.bodyTruncated !== undefined && {
"body.truncated": stored.bodyTruncated,
}),
...(entry.traceId && { "trace.id": entry.traceId }),
...(entry.spanId && { "span.id": entry.spanId }),
...(entry.metadata && {
"body.metadata_json": JSON.stringify(entry.metadata),
}),
source: "otlp",
},
});
};
if (isProxyOtelOnly()) {
return publishProxyOtelBody(captureId, stored.redactedBody, emit);
}
const chunks = splitUtf8StringByBytes(stored.redactedBody, BODY_OTLP_CHUNK_SIZE);
for (let i = 0; i < chunks.length; i++) {
if (i > 0 && i % 4 === 0) {
await yieldToRequests();
}
emit(chunks[i], i, chunks.length);
}
return undefined;
})
.catch(() => {
// Non-fatal — never crash proxy for OTLP log failures
return undefined;
});
}
/** Cheap gate for response observers; avoid copying diagnostics on the serving path. */
export function isProxyBodyCaptureEnabled() {
return logEnabled && (Boolean(logDir) || isProxyOtelOnly());
}
/** Capture an owned request body with bounded processing and tracked index/export publication. */
export async function logBodyCapture(entry) {
if (!isProxyBodyCaptureEnabled()) {
return;
}
// Borrowed traffic is somebody else's conversation. Capturing it would leave
// a peer's prompts and the model's replies on this machine's disk, which is
// not something a share token can be read as consenting to. The request is
// still logged; only the bodies are dropped.
if (isBorrowedRequest()) {
emitProxyOtelEvent("body_capture_index", {
timestamp: entry.timestamp,
requestId: entry.requestId,
captureId: entry.captureId ?? randomUUID(),
phase: entry.phase,
bodyDelivery: { status: "policy_excluded", reason: "borrowed_traffic" },
});
return;
}
const traceCtx = resolveProxyLogTraceContext(entry);
const destination = logDir;
// Publication callbacks retain metadata and the bounded redacted result,
// never the original unbounded body while a sink is slow.
const metadata = {
...entry,
captureId: entry.captureId ?? randomUUID(),
body: undefined,
};
/** Persist the processed capture index and publish its redacted body before releasing capacity. */
const consume = async (processed) => {
const redactedHeaders = processed.headers;
const stored = processed.stored;
const dateStr = new Date(metadata.timestamp).toISOString().split("T")[0];
const logFile = destination
? join(destination, `proxy-debug-${dateStr}.jsonl`)
: undefined;
const indexEntry = {
timestamp: metadata.timestamp,
type: "body_capture",
requestId: metadata.requestId,
captureId: metadata.captureId,
phase: metadata.phase,
model: metadata.model,
stream: metadata.stream,
headers: redactedHeaders,
contentType: metadata.contentType,
responseStatus: metadata.responseStatus,
durationMs: metadata.durationMs,
account: metadata.account,
accountType: metadata.accountType,
attempt: metadata.attempt,
bodyPath: stored.bodyPath,
bodySha256: stored.bodySha256,
observedBodyBytes: metadata.bodySize,
redactedBodyBytes: stored.redactedBodyBytes,
storedFileBytes: stored.storedFileBytes,
bodyTruncated: stored.bodyTruncated,
bodyCaptureLimitBytes: stored.bodyCaptureLimitBytes,
originalRedactedBodyBytes: stored.originalRedactedBodyBytes,
inputRetainedBytes: stored.inputRetainedBytes,
inputEncoding: stored.inputEncoding,
sourceTruncated: stored.sourceTruncated,
processingTruncated: stored.processingTruncated,
redactionLossy: stored.redactionLossy,
unparseableRedactedFrames: stored.unparseableRedactedFrames,
bodyWriteFailed: stored.bodyWriteFailed,
captureError: processed.error,
captureAdmission: processed.admission,
captureQueueWaitMs: processed.queueWaitMs,
captureProcessingMs: processed.processingMs,
captureAdmissionWaitMs: processed.admissionWaitMs,
captureWorkerQueueWaitMs: processed.workerQueueWaitMs,
metadata: processed.error ? undefined : metadata.metadata,
};
if (traceCtx) {
indexEntry.traceId = traceCtx.traceId;
indexEntry.spanId = traceCtx.spanId;
indexEntry.traceFlags = traceCtx.traceFlags;
}
if (isProxyOtelOnly()) {
const publication = await bodyCapturePolicy.publish(metadata, stored, () => emitOtlpBodyLogRecord({
...metadata,
traceId: traceCtx?.traceId ?? metadata.traceId,
spanId: traceCtx?.spanId ?? metadata.spanId,
traceFlags: traceCtx?.traceFlags ?? metadata.traceFlags,
}, stored));
indexEntry.bodyReference = publication.reference;
indexEntry.bodyDelivery = publication.delivery ?? {
status: processed.error
? "capture_rejected"
: stored.redactedBody === undefined
? "no_body"
: "export_unconfirmed",
...(processed.error ? { reason: processed.error } : {}),
};
emitProxyOtelEvent("body_capture_index", indexEntry);
return;
}
try {
if (logFile) {
await appendMetadataRecord(logFile, JSON.stringify(indexEntry) + "\n", "debug", { waitForPersistence: true });
}
}
catch {
// Non-fatal
}
// Emission yields between chunk groups. Keep it in the shutdown flush set
// so an exporter flush cannot race unfinished body-log publication.
await emitOtlpBodyLogRecord({
...metadata,
traceId: traceCtx?.traceId ?? metadata.traceId,
spanId: traceCtx?.spanId ?? metadata.spanId,
traceFlags: traceCtx?.traceFlags ?? metadata.traceFlags,
}, stored);
};
const operation = trackLogOperation(captureProxyBody(entry, destination, consume));
// HTTP handlers may await this function. Collector latency must never hold
// their response open; shutdown uses flushRequestLogs as the completion fence.
if (isProxyOtelOnly()) {
void operation;
return;
}
return operation;
}
/**
* Log the FULL raw request and response for debugging.
* Legacy helper kept for compatibility. New call sites should prefer
* logBodyCapture() so each phase can be indexed and persisted separately.
*/
export async function logFullRequestResponse(entry) {
await Promise.all([
logBodyCapture({
timestamp: entry.timestamp,
requestId: entry.requestId,
phase: "legacy_upstream_request",
model: entry.model,
stream: entry.stream,
headers: entry.requestHeaders,
body: entry.requestBody,
bodySize: entry.requestBodySize,
contentType: entry.requestHeaders["content-type"] ?? "application/json",
account: entry.account,
responseStatus: entry.responseStatus,
durationMs: entry.durationMs,
}),
logBodyCapture({
timestamp: entry.timestamp,
requestId: entry.requestId,
phase: "legacy_upstream_response",
model: entry.model,
stream: entry.stream,
headers: entry.responseHeaders,
body: entry.responseBody,
bodySize: entry.responseBodySize,
contentType: entry.responseHeaders?.["content-type"] ?? "application/json",
account: entry.account,
responseStatus: entry.responseStatus,
durationMs: entry.durationMs,
}),
]);
}
/**
* Log a mid-stream error that occurs after the initial 200 was sent.
* These are invisible in normal request logs since the 200 was already recorded.
*/
export async function logStreamError(entry) {
if (!logEnabled || (!logDir && !isProxyOtelOnly())) {
return;
}
const bridge = new OtelBridge();
const traceCtx = bridge.getCurrentTraceContext();
const logEntry = {
...entry,
responseStatus: 200,
terminalStatus: 502,
terminalOutcome: "stream_error",
errorType: "stream_error",
note: "mid-stream failure after initial 200",
};
if (traceCtx) {
logEntry.traceId = traceCtx.traceId;
logEntry.spanId = traceCtx.spanId;
}
if (isProxyOtelOnly()) {
emitProxyOtelEvent("stream_error", logEntry);
return;
}
const logFile = join(logDir, `proxy-${new Date().toISOString().split("T")[0]}.jsonl`);
try {
await appendMetadataRecord(logFile, JSON.stringify(logEntry) + "\n", "requests");
}
catch {
// Non-fatal — don't crash proxy for logging failures
}
}
/**
* Clean up old log files by age and total size.
* - Deletes files older than maxAgeDays
* - If remaining files exceed maxSizeMb, deletes oldest until under limit
* Non-fatal — proxy keeps working even if cleanup fails.
*/
export function cleanupLogs(maxAgeDays = 7, maxSizeMb = 500) {
if (!logDir) {
return;
}
try {
cleanupLogsAt(logDir, maxAgeDays, maxSizeMb);
}
catch {
// Non-fatal for legacy in-process callers.
}
}
/**
* Path-scoped retention implementation used by the proxy cleanup worker.
* This function is intentionally synchronous: callers must run it outside the
* request-serving process when the directory can contain many artifacts.
*/
export function cleanupLogsAt(activeLogDir, maxAgeDays = 7, maxSizeMb = 500) {
if (!existsSync(activeLogDir)) {
return;
}
const files = collectManagedLogFiles(activeLogDir).sort((a, b) => a.mtime - b.mtime); // oldest first
const currentDate = new Date().toISOString().split("T")[0];
const currentMetadataLogs = new Set([
"proxy",
"proxy-attempts",
"proxy-debug",
"proxy-lifecycle",
"proxy-supervisor",
].map((prefix) => join(activeLogDir, `${prefix}-${currentDate}.jsonl`)));
const canDelete = (file) => !currentMetadataLogs.has(file.path);
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
let deletedCount = 0;
let freedBytes = 0;
// Pass 1: delete files older than maxAgeDays
const remaining = [];
for (const file of files) {
if (file.mtime < cutoff && canDelete(file)) {
unlinkSync(file.path);
deletedCount++;
freedBytes += file.size;
}
else {
remaining.push(file);
}
}
const bodiesDir = join(activeLogDir, "bodies");
if (existsSync(bodiesDir)) {
pruneEmptyDirectories(bodiesDir, bodiesDir);
}
// Pass 2: if total size exceeds maxSizeMb, delete oldest until under limit
const maxBytes = maxSizeMb * 1024 * 1024;
let totalSize = remaining.reduce((sum, f) => sum + f.size, 0);
const deletionCandidates = remaining.filter(canDelete);
// Current-day metadata is the only reliable source for final-request,
// attempt, lifecycle, and body-index reconciliation. Keep those indexes
// intact during size cleanup; body artifacts and older indexes remain
// eligible for eviction.
while (totalSize > maxBytes && deletionCandidates.length > 0) {
const oldest = deletionCandidates.shift();
if (!oldest) {
break;
}
unlinkSync(oldest.path);
totalSize -= oldest.size;
deletedCount++;
freedBytes += oldest.size;
}
if (existsSync(bodiesDir)) {
pruneEmptyDirectories(bodiesDir, bodiesDir);
}
if (deletedCount > 0) {
logger.info(`[proxy] log cleanup: deleted ${deletedCount} file(s), freed ${(freedBytes / 1024 / 1024).toFixed(1)} MB`);
}
}