UNPKG

@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
/** * 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`); } }