| author | |
| committer | |
| log | 86e87a8789196663e5e919466676566dadb32f20 |
| tree | 6874093cfabd71d06a220bf53837718f9ffbee8c |
| parent | 404e78a506ad1a6b0bd55eaa703fb0563e7dea7d |
5 files changed, 390 insertions(+), 274 deletions(-)
apps/server/src/provider/Layers/CodexAdapter.ts+31-31| ... | @@ -436,7 +436,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -436,7 +436,9 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 436 | const nativeEventLogger = | 436 | const nativeEventLogger = |
| 437 | options?.nativeEventLogger ?? | 437 | options?.nativeEventLogger ?? |
| 438 | (options?.nativeEventLogPath !== undefined | 438 | (options?.nativeEventLogPath !== undefined |
| 439 | ? makeEventNdjsonLogger(options.nativeEventLogPath) | 439 | ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { |
| 440 | stream: "native", | ||
| 441 | }) | ||
| 440 | : undefined); | 442 | : undefined); |
| 441 | 443 | ||
| 442 | const manager = yield* Effect.acquireRelease( | 444 | const manager = yield* Effect.acquireRelease( |
| ... | @@ -499,9 +501,11 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -499,9 +501,11 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 499 | new Error(`Invalid attachment id '${attachment.id}'.`), | 501 | new Error(`Invalid attachment id '${attachment.id}'.`), |
| 500 | ); | 502 | ); |
| 501 | } | 503 | } |
| 502 | const bytes = yield* fileSystem.readFile(attachmentPath).pipe( | 504 | const bytes = yield* fileSystem |
| 503 | Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)), | 505 | .readFile(attachmentPath) |
| 504 | ); | 506 | .pipe( |
| 507 | Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)), | ||
| 508 | ); | ||
| 505 | return { | 509 | return { |
| 506 | type: "image" as const, | 510 | type: "image" as const, |
| 507 | url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, | 511 | url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, |
| ... | @@ -583,33 +587,29 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => | ... | @@ -583,33 +587,29 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 583 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | 587 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); |
| 584 | 588 | ||
| 585 | yield* Effect.acquireRelease( | 589 | yield* Effect.acquireRelease( |
| 586 | Effect.sync(() => { | 590 | Effect.gen(function* () { |
| 587 | const writeNativeEvent = (event: ProviderEvent) => { | 591 | const writeNativeEvent = (event: ProviderEvent) => |
| 588 | if (!nativeEventLogger) { | 592 | Effect.gen(function* () { |
| 589 | return; | 593 | if (!nativeEventLogger) { |
| 590 | } | 594 | return; |
| 591 | const loggedAt = new Date().toISOString(); | 595 | } |
| 592 | const resolveOrchestrationThreadId = Effect.catch( | 596 | const orchestrationThreadId = yield* Effect.catch( |
| 593 | directory.getThreadId(event.sessionId).pipe(Effect.map(Option.getOrUndefined)), | 597 | directory.getThreadId(event.sessionId).pipe( |
| 594 | () => Effect.void, | 598 | Effect.map((threadIdOption) => |
| 595 | ); | 599 | Option.isSome(threadIdOption) ? threadIdOption.value : null, |
| 596 | const writeLogEffect = Effect.tap(resolveOrchestrationThreadId, (orchestrationThreadId) => | 600 | ), |
| 597 | Effect.sync(() => { | 601 | ), |
| 598 | nativeEventLogger.write({ | 602 | () => Effect.succeed(null), |
| 599 | observedAt: loggedAt, | 603 | ); |
| 600 | stream: "native", | 604 | yield* nativeEventLogger.write(event, orchestrationThreadId); |
| 601 | orchestrationThreadId: orchestrationThreadId ?? null, | 605 | }); |
| 602 | event, | 606 | |
| 603 | }); | 607 | const services = yield* Effect.services<never>(); |
| 604 | }), | 608 | const listener = (event: ProviderEvent) => |
| 605 | ); | 609 | Effect.gen(function* () { |
| 606 | void Effect.runPromise(writeLogEffect); | 610 | yield* writeNativeEvent(event); |
| 607 | }; | 611 | yield* Queue.offerAll(runtimeEventQueue, mapToRuntimeEvents(event)); |
| 608 | 612 | }).pipe(Effect.runPromiseWith(services)); | |
| 609 | const listener = (event: ProviderEvent) => { | ||
| 610 | writeNativeEvent(event); | ||
| 611 | Queue.offerAllUnsafe(runtimeEventQueue, mapToRuntimeEvents(event)); | ||
| 612 | }; | ||
| 613 | manager.on("event", listener); | 613 | manager.on("event", listener); |
| 614 | return listener; | 614 | return listener; |
| 615 | }), | 615 | }), |
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+142-101| ... | @@ -2,116 +2,157 @@ import fs from "node:fs"; | ... | @@ -2,116 +2,157 @@ import fs from "node:fs"; |
| 2 | import os from "node:os"; | 2 | import os from "node:os"; |
| 3 | import path from "node:path"; | 3 | import path from "node:path"; |
| 4 | 4 | ||
| 5 | import { describe, expect, it } from "vitest"; | 5 | import { ThreadId } from "@t3tools/contracts"; |
| 6 | import { assert, describe, it } from "@effect/vitest"; | ||
| 7 | import { Effect } from "effect"; | ||
| 6 | 8 | ||
| 7 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 9 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 8 | 10 | ||
| 11 | function parseLogLine(line: string) { | ||
| 12 | const match = /^\[([^\]]+)\] ([A-Z]+): (.+)$/.exec(line); | ||
| 13 | assert.notEqual(match, null); | ||
| 14 | if (!match) { | ||
| 15 | throw new Error(`invalid log line: ${line}`); | ||
| 16 | } | ||
| 17 | const observedAt = match[1]; | ||
| 18 | const stream = match[2]; | ||
| 19 | const payload = match[3]; | ||
| 20 | if (!observedAt || !stream || payload === undefined) { | ||
| 21 | throw new Error(`invalid log line: ${line}`); | ||
| 22 | } | ||
| 23 | return { | ||
| 24 | observedAt, | ||
| 25 | stream, | ||
| 26 | payload, | ||
| 27 | }; | ||
| 28 | } | ||
| 29 | |||
| 9 | describe("EventNdjsonLogger", () => { | 30 | describe("EventNdjsonLogger", () => { |
| 10 | it("writes effect-style lines to thread-scoped files", () => { | 31 | it.effect("writes effect-style lines to thread-scoped files", () => |
| 11 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); | 32 | Effect.gen(function* () { |
| 12 | const basePath = path.join(tempDir, "provider-native.ndjson"); | 33 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); |
| 13 | 34 | const basePath = path.join(tempDir, "provider-native.ndjson"); | |
| 14 | try { | 35 | |
| 15 | const logger = makeEventNdjsonLogger(basePath); | 36 | try { |
| 16 | expect(logger).toBeDefined(); | 37 | const logger = yield* makeEventNdjsonLogger(basePath, { stream: "native" }); |
| 17 | if (!logger) { | 38 | assert.notEqual(logger, undefined); |
| 18 | return; | 39 | if (!logger) { |
| 40 | return; | ||
| 41 | } | ||
| 42 | |||
| 43 | yield* logger.write( | ||
| 44 | { threadId: "provider-thread-1", id: "evt-1" }, | ||
| 45 | ThreadId.makeUnsafe("thread-1"), | ||
| 46 | ); | ||
| 47 | yield* logger.write( | ||
| 48 | { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, | ||
| 49 | ThreadId.makeUnsafe("thread-2"), | ||
| 50 | ); | ||
| 51 | yield* logger.close(); | ||
| 52 | |||
| 53 | const threadOnePath = path.join(tempDir, "thread-1.log"); | ||
| 54 | const threadTwoPath = path.join(tempDir, "thread-2.log"); | ||
| 55 | assert.equal(fs.existsSync(threadOnePath), true); | ||
| 56 | assert.equal(fs.existsSync(threadTwoPath), true); | ||
| 57 | |||
| 58 | const first = parseLogLine(fs.readFileSync(threadOnePath, "utf8").trim()); | ||
| 59 | const second = parseLogLine(fs.readFileSync(threadTwoPath, "utf8").trim()); | ||
| 60 | |||
| 61 | assert.equal(Number.isNaN(Date.parse(first.observedAt)), false); | ||
| 62 | assert.equal(first.stream, "NATIVE"); | ||
| 63 | assert.equal(first.payload, '{"threadId":"provider-thread-1","id":"evt-1"}'); | ||
| 64 | |||
| 65 | assert.equal(Number.isNaN(Date.parse(second.observedAt)), false); | ||
| 66 | assert.equal(second.stream, "NATIVE"); | ||
| 67 | assert.equal( | ||
| 68 | second.payload, | ||
| 69 | '{"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', | ||
| 70 | ); | ||
| 71 | } finally { | ||
| 72 | fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 19 | } | 73 | } |
| 20 | 74 | }), | |
| 21 | logger.write({ | 75 | ); |
| 22 | observedAt: "2026-03-03T12:00:00.000Z", | 76 | |
| 23 | stream: "native", | 77 | it.effect("falls back to a global segment when orchestration thread id is missing or invalid", () => |
| 24 | orchestrationThreadId: "thread-1", | 78 | Effect.gen(function* () { |
| 25 | event: { threadId: "provider-thread-1", id: "evt-1" }, | 79 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); |
| 26 | }); | 80 | const basePath = path.join(tempDir, "provider-canonical.ndjson"); |
| 27 | logger.write({ | 81 | |
| 28 | observedAt: "2026-03-03T12:00:01.000Z", | 82 | try { |
| 29 | stream: "canonical", | 83 | const logger = yield* makeEventNdjsonLogger(basePath, { stream: "orchestration" }); |
| 30 | orchestrationThreadId: "thread-2", | 84 | assert.notEqual(logger, undefined); |
| 31 | event: { type: "turn.completed", threadId: "provider-thread-2", id: "evt-2" }, | 85 | if (!logger) { |
| 32 | }); | 86 | return; |
| 33 | logger.close(); | 87 | } |
| 34 | 88 | ||
| 35 | const threadOnePath = path.join(tempDir, "thread-1.log"); | 89 | yield* logger.write({ id: "evt-no-thread" }, null); |
| 36 | const threadTwoPath = path.join(tempDir, "thread-2.log"); | 90 | yield* logger.write({ id: "evt-invalid-thread" }, "!!!" as unknown as ThreadId); |
| 37 | expect(fs.existsSync(threadOnePath)).toBe(true); | 91 | yield* logger.close(); |
| 38 | expect(fs.existsSync(threadTwoPath)).toBe(true); | 92 | |
| 39 | 93 | const globalPath = path.join(tempDir, "_global.log"); | |
| 40 | const first = fs.readFileSync(threadOnePath, "utf8").trim(); | 94 | assert.equal(fs.existsSync(globalPath), true); |
| 41 | const second = fs.readFileSync(threadTwoPath, "utf8").trim(); | 95 | const lines = fs |
| 42 | expect(first).toBe( | 96 | .readFileSync(globalPath, "utf8") |
| 43 | '[2026-03-03T12:00:00.000Z] NATIVE: {"threadId":"provider-thread-1","id":"evt-1"}', | 97 | .trim() |
| 44 | ); | 98 | .split("\n") |
| 45 | expect(second).toBe( | 99 | .map((line) => parseLogLine(line)); |
| 46 | '[2026-03-03T12:00:01.000Z] ORCHESTRATION: {"type":"turn.completed","threadId":"provider-thread-2","id":"evt-2"}', | 100 | assert.equal(lines.length, 2); |
| 47 | ); | 101 | assert.equal(Number.isNaN(Date.parse(lines[0]?.observedAt ?? "")), false); |
| 48 | } finally { | 102 | assert.equal(Number.isNaN(Date.parse(lines[1]?.observedAt ?? "")), false); |
| 49 | fs.rmSync(tempDir, { recursive: true, force: true }); | 103 | assert.equal(lines[0]?.stream, "ORCHESTRATION"); |
| 50 | } | 104 | assert.equal(lines[0]?.payload, '{"id":"evt-no-thread"}'); |
| 51 | }); | 105 | assert.equal(lines[1]?.stream, "ORCHESTRATION"); |
| 52 | 106 | assert.equal(lines[1]?.payload, '{"id":"evt-invalid-thread"}'); | |
| 53 | it("drops records without orchestration thread ids", () => { | 107 | } finally { |
| 54 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); | 108 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 55 | const basePath = path.join(tempDir, "provider-canonical.ndjson"); | ||
| 56 | |||
| 57 | try { | ||
| 58 | const logger = makeEventNdjsonLogger(basePath); | ||
| 59 | expect(logger).toBeDefined(); | ||
| 60 | if (!logger) { | ||
| 61 | return; | ||
| 62 | } | 109 | } |
| 110 | }), | ||
| 111 | ); | ||
| 63 | 112 | ||
| 64 | logger.write({ stream: "orchestration", event: { id: "evt-no-thread" } }); | 113 | it.effect("rotates per-thread files when max size is exceeded", () => |
| 65 | logger.close(); | 114 | Effect.gen(function* () { |
| 66 | 115 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); | |
| 67 | const logFiles = fs.readdirSync(tempDir).filter((entry) => entry.endsWith(".log")); | 116 | const basePath = path.join(tempDir, "provider-native.ndjson"); |
| 68 | expect(logFiles).toEqual([]); | ||
| 69 | } finally { | ||
| 70 | fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 71 | } | ||
| 72 | }); | ||
| 73 | |||
| 74 | it("rotates per-thread files when max size is exceeded", () => { | ||
| 75 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); | ||
| 76 | const basePath = path.join(tempDir, "provider-native.ndjson"); | ||
| 77 | |||
| 78 | try { | ||
| 79 | const logger = makeEventNdjsonLogger(basePath, { | ||
| 80 | maxBytes: 120, | ||
| 81 | maxFiles: 2, | ||
| 82 | }); | ||
| 83 | expect(logger).toBeDefined(); | ||
| 84 | if (!logger) { | ||
| 85 | return; | ||
| 86 | } | ||
| 87 | 117 | ||
| 88 | for (let index = 0; index < 10; index += 1) { | 118 | try { |
| 89 | logger.write({ | 119 | const logger = yield* makeEventNdjsonLogger(basePath, { |
| 90 | stream: "native", | 120 | stream: "native", |
| 91 | orchestrationThreadId: "thread-rotate", | 121 | maxBytes: 120, |
| 92 | event: { | 122 | maxFiles: 2, |
| 93 | threadId: "provider-thread-rotate", | ||
| 94 | id: `evt-${index}`, | ||
| 95 | payload: "x".repeat(40), | ||
| 96 | }, | ||
| 97 | }); | 123 | }); |
| 124 | assert.notEqual(logger, undefined); | ||
| 125 | if (!logger) { | ||
| 126 | return; | ||
| 127 | } | ||
| 128 | |||
| 129 | for (let index = 0; index < 10; index += 1) { | ||
| 130 | yield* logger.write( | ||
| 131 | { | ||
| 132 | threadId: "provider-thread-rotate", | ||
| 133 | id: `evt-${index}`, | ||
| 134 | payload: "x".repeat(40), | ||
| 135 | }, | ||
| 136 | ThreadId.makeUnsafe("thread-rotate"), | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | yield* logger.close(); | ||
| 140 | |||
| 141 | const fileStem = "thread-rotate.log"; | ||
| 142 | const matchingFiles = fs | ||
| 143 | .readdirSync(tempDir) | ||
| 144 | .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) | ||
| 145 | .toSorted(); | ||
| 146 | |||
| 147 | assert.equal(matchingFiles.some((entry) => entry === `${fileStem}.1`), true); | ||
| 148 | assert.equal( | ||
| 149 | matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`), | ||
| 150 | true, | ||
| 151 | ); | ||
| 152 | assert.equal(matchingFiles.some((entry) => entry === `${fileStem}.3`), false); | ||
| 153 | } finally { | ||
| 154 | fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 98 | } | 155 | } |
| 99 | logger.close(); | 156 | }), |
| 100 | 157 | ); | |
| 101 | const fileStem = "thread-rotate.log"; | ||
| 102 | const matchingFiles = fs | ||
| 103 | .readdirSync(tempDir) | ||
| 104 | .filter((entry) => entry === fileStem || entry.startsWith(`${fileStem}.`)) | ||
| 105 | .toSorted(); | ||
| 106 | |||
| 107 | expect(matchingFiles.some((entry) => entry === `${fileStem}.1`)).toBe(true); | ||
| 108 | expect(matchingFiles.some((entry) => entry === fileStem || entry === `${fileStem}.2`)).toBe( | ||
| 109 | true, | ||
| 110 | ); | ||
| 111 | expect(matchingFiles.some((entry) => entry === `${fileStem}.3`)).toBe(false); | ||
| 112 | } finally { | ||
| 113 | fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 114 | } | ||
| 115 | }); | ||
| 116 | |||
| 117 | }); | 158 | }); |
apps/server/src/provider/Layers/EventNdjsonLogger.ts+199-127| ... | @@ -8,178 +8,250 @@ | ... | @@ -8,178 +8,250 @@ |
| 8 | import fs from "node:fs"; | 8 | import fs from "node:fs"; |
| 9 | import path from "node:path"; | 9 | import path from "node:path"; |
| 10 | 10 | ||
| 11 | import type { ThreadId } from "@t3tools/contracts"; | ||
| 11 | import { RotatingFileSink } from "@t3tools/shared/logging"; | 12 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 13 | import { Effect, Exit, Logger, Scope } from "effect"; | ||
| 14 | |||
| 12 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; | 15 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; |
| 13 | import { createLogger } from "../../logger.ts"; | ||
| 14 | 16 | ||
| 15 | const logger = createLogger("provider-observability"); | ||
| 16 | const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; | 17 | const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; |
| 17 | const DEFAULT_MAX_FILES = 10; | 18 | const DEFAULT_MAX_FILES = 10; |
| 19 | const DEFAULT_BATCH_WINDOW_MS = 200; | ||
| 20 | const GLOBAL_THREAD_SEGMENT = "_global"; | ||
| 21 | const LOG_SCOPE = "provider-observability"; | ||
| 22 | |||
| 23 | export type EventNdjsonStream = "native" | "canonical" | "orchestration"; | ||
| 18 | 24 | ||
| 19 | export interface EventNdjsonLogger { | 25 | export interface EventNdjsonLogger { |
| 20 | readonly filePath: string; | 26 | readonly filePath: string; |
| 21 | write: (record: unknown) => void; | 27 | write: (event: unknown, threadId: ThreadId | null) => Effect.Effect<void>; |
| 22 | close: () => void; | 28 | close: () => Effect.Effect<void>; |
| 23 | } | 29 | } |
| 24 | 30 | ||
| 25 | export interface EventNdjsonLoggerOptions { | 31 | export interface EventNdjsonLoggerOptions { |
| 32 | readonly stream: EventNdjsonStream; | ||
| 26 | readonly maxBytes?: number; | 33 | readonly maxBytes?: number; |
| 27 | readonly maxFiles?: number; | 34 | readonly maxFiles?: number; |
| 35 | readonly batchWindowMs?: number; | ||
| 28 | } | 36 | } |
| 29 | 37 | ||
| 30 | function stringifyRecord(record: unknown): string | undefined { | 38 | interface ThreadWriter { |
| 31 | try { | 39 | writeMessage: (message: string) => Effect.Effect<void>; |
| 32 | return JSON.stringify(record); | 40 | close: () => Effect.Effect<void>; |
| 33 | } catch (error) { | ||
| 34 | logger.warn("failed to serialize provider event log record", { error }); | ||
| 35 | return undefined; | ||
| 36 | } | ||
| 37 | } | 41 | } |
| 38 | 42 | ||
| 39 | function normalizeThreadSegment(raw: unknown): string | null { | 43 | function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> { |
| 40 | if (typeof raw !== "string") { | 44 | return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE })); |
| 41 | return null; | ||
| 42 | } | ||
| 43 | return toSafeThreadAttachmentSegment(raw); | ||
| 44 | } | 45 | } |
| 45 | 46 | ||
| 46 | function asObject(value: unknown): Record<string, unknown> | undefined { | 47 | function resolveThreadSegment(raw: string | null | undefined): string { |
| 47 | if (!value || typeof value !== "object" || Array.isArray(value)) { | 48 | const normalized = typeof raw === "string" ? toSafeThreadAttachmentSegment(raw) : null; |
| 48 | return undefined; | 49 | return normalized ?? GLOBAL_THREAD_SEGMENT; |
| 49 | } | ||
| 50 | return value as Record<string, unknown>; | ||
| 51 | } | 50 | } |
| 52 | 51 | ||
| 53 | function resolveThreadSegment(record: unknown): string | null { | 52 | function formatLoggerMessage(message: unknown): string { |
| 54 | const root = asObject(record); | 53 | if (Array.isArray(message)) { |
| 55 | return normalizeThreadSegment(root?.orchestrationThreadId); | 54 | return message.map((part) => (typeof part === "string" ? part : String(part))).join(" "); |
| 55 | } | ||
| 56 | return typeof message === "string" ? message : String(message); | ||
| 56 | } | 57 | } |
| 57 | 58 | ||
| 58 | function resolveThreadFilePath(baseFilePath: string, threadSegment: string): string { | 59 | function makeLineLogger(streamLabel: string): Logger.Logger<unknown, string> { |
| 59 | const dir = path.dirname(baseFilePath); | 60 | return Logger.make( |
| 60 | return path.join(dir, `${threadSegment}.log`); | 61 | ({ date, message }) => |
| 62 | `[${date.toISOString()}] ${streamLabel}: ${formatLoggerMessage(message)}\n`, | ||
| 63 | ); | ||
| 61 | } | 64 | } |
| 62 | 65 | ||
| 63 | function resolveObservedAt(raw: unknown): string { | 66 | function resolveStreamLabel(stream: EventNdjsonStream): string { |
| 64 | if (typeof raw !== "string") { | 67 | switch (stream) { |
| 65 | return new Date().toISOString(); | 68 | case "native": |
| 66 | } | 69 | return "NTIVE"; |
| 67 | const trimmed = raw.trim(); | 70 | case "canonical": |
| 68 | if (trimmed.length === 0) { | 71 | case "orchestration": |
| 69 | return new Date().toISOString(); | 72 | default: |
| 73 | return "CANON"; | ||
| 70 | } | 74 | } |
| 71 | return trimmed; | ||
| 72 | } | 75 | } |
| 73 | 76 | ||
| 74 | function resolveStreamLabel(raw: unknown): string { | 77 | function toLogMessage(event: unknown): Effect.Effect<string | undefined> { |
| 75 | if (typeof raw !== "string") { | 78 | return Effect.gen(function* () { |
| 76 | return "ORCHESTRATION"; | 79 | const serialized = yield* Effect.sync(() => { |
| 77 | } | 80 | try { |
| 78 | const normalized = raw.trim().toLowerCase(); | 81 | return { ok: true as const, value: JSON.stringify(event) }; |
| 79 | if (normalized === "native") { | 82 | } catch (error) { |
| 80 | return "NATIVE"; | 83 | return { ok: false as const, error }; |
| 81 | } | 84 | } |
| 82 | if (normalized === "canonical" || normalized === "orchestration") { | 85 | }); |
| 83 | return "ORCHESTRATION"; | 86 | |
| 84 | } | 87 | if (!serialized.ok) { |
| 85 | if (normalized.length === 0) { | 88 | yield* logWarning("failed to serialize provider event log record", { |
| 86 | return "ORCHESTRATION"; | 89 | error: serialized.error, |
| 87 | } | 90 | }); |
| 88 | return normalized.toUpperCase(); | 91 | return undefined; |
| 92 | } | ||
| 93 | |||
| 94 | if (typeof serialized.value !== "string") { | ||
| 95 | return undefined; | ||
| 96 | } | ||
| 97 | |||
| 98 | return serialized.value; | ||
| 99 | }); | ||
| 89 | } | 100 | } |
| 90 | 101 | ||
| 91 | function toLogLine(record: unknown): string | undefined { | 102 | function makeThreadWriter(input: { |
| 92 | const root = asObject(record); | 103 | readonly filePath: string; |
| 93 | const payload = root && "event" in root ? root.event : record; | 104 | readonly maxBytes: number; |
| 94 | const serializedPayload = stringifyRecord(payload); | 105 | readonly maxFiles: number; |
| 95 | if (!serializedPayload) { | 106 | readonly batchWindowMs: number; |
| 96 | return undefined; | 107 | readonly streamLabel: string; |
| 97 | } | 108 | }): Effect.Effect<ThreadWriter | undefined> { |
| 98 | const observedAt = resolveObservedAt(root?.observedAt); | 109 | return Effect.gen(function* () { |
| 99 | const streamLabel = resolveStreamLabel(root?.stream); | 110 | const sinkResult = yield* Effect.sync(() => { |
| 100 | return `[${observedAt}] ${streamLabel}: ${serializedPayload}\n`; | 111 | try { |
| 112 | return { | ||
| 113 | ok: true as const, | ||
| 114 | sink: new RotatingFileSink({ | ||
| 115 | filePath: input.filePath, | ||
| 116 | maxBytes: input.maxBytes, | ||
| 117 | maxFiles: input.maxFiles, | ||
| 118 | throwOnError: true, | ||
| 119 | }), | ||
| 120 | }; | ||
| 121 | } catch (error) { | ||
| 122 | return { ok: false as const, error }; | ||
| 123 | } | ||
| 124 | }); | ||
| 125 | |||
| 126 | if (!sinkResult.ok) { | ||
| 127 | yield* logWarning("failed to initialize provider thread log file", { | ||
| 128 | filePath: input.filePath, | ||
| 129 | error: sinkResult.error, | ||
| 130 | }); | ||
| 131 | return undefined; | ||
| 132 | } | ||
| 133 | |||
| 134 | const sink = sinkResult.sink; | ||
| 135 | const scope = yield* Scope.make(); | ||
| 136 | const lineLogger = makeLineLogger(input.streamLabel); | ||
| 137 | const batchedLogger = yield* Logger.batched(lineLogger, { | ||
| 138 | window: input.batchWindowMs, | ||
| 139 | flush: (messages) => | ||
| 140 | Effect.gen(function* () { | ||
| 141 | const flushResult = yield* Effect.sync(() => { | ||
| 142 | try { | ||
| 143 | for (const message of messages) { | ||
| 144 | sink.write(message); | ||
| 145 | } | ||
| 146 | return { ok: true as const }; | ||
| 147 | } catch (error) { | ||
| 148 | return { ok: false as const, error }; | ||
| 149 | } | ||
| 150 | }); | ||
| 151 | |||
| 152 | if (!flushResult.ok) { | ||
| 153 | yield* logWarning("provider event log batch flush failed", { | ||
| 154 | filePath: input.filePath, | ||
| 155 | error: flushResult.error, | ||
| 156 | }); | ||
| 157 | } | ||
| 158 | }), | ||
| 159 | }).pipe(Effect.provideService(Scope.Scope, scope)); | ||
| 160 | |||
| 161 | const loggerLayer = Logger.layer([batchedLogger], { mergeWithExisting: false }); | ||
| 162 | |||
| 163 | return { | ||
| 164 | writeMessage(message: string) { | ||
| 165 | return Effect.log(message).pipe(Effect.provide(loggerLayer)); | ||
| 166 | }, | ||
| 167 | close() { | ||
| 168 | return Scope.close(scope, Exit.void); | ||
| 169 | }, | ||
| 170 | } satisfies ThreadWriter; | ||
| 171 | }); | ||
| 101 | } | 172 | } |
| 102 | 173 | ||
| 103 | export function makeEventNdjsonLogger( | 174 | export function makeEventNdjsonLogger( |
| 104 | filePath: string, | 175 | filePath: string, |
| 105 | options?: EventNdjsonLoggerOptions, | 176 | options: EventNdjsonLoggerOptions, |
| 106 | ): EventNdjsonLogger | undefined { | 177 | ): Effect.Effect<EventNdjsonLogger | undefined> { |
| 107 | const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES; | 178 | return Effect.gen(function* () { |
| 108 | const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES; | 179 | const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; |
| 109 | 180 | const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES; | |
| 110 | try { | 181 | const batchWindowMs = options.batchWindowMs ?? DEFAULT_BATCH_WINDOW_MS; |
| 111 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); | 182 | const streamLabel = resolveStreamLabel(options.stream); |
| 112 | } catch (error) { | 183 | |
| 113 | logger.warn("failed to create provider event log directory", { filePath, error }); | 184 | const directoryReady = yield* Effect.sync(() => { |
| 114 | return undefined; | 185 | try { |
| 115 | } | 186 | fs.mkdirSync(path.dirname(filePath), { recursive: true }); |
| 187 | return true; | ||
| 188 | } catch (error) { | ||
| 189 | return { ok: false as const, error }; | ||
| 190 | } | ||
| 191 | }); | ||
| 192 | if (directoryReady !== true) { | ||
| 193 | yield* logWarning("failed to create provider event log directory", { | ||
| 194 | filePath, | ||
| 195 | error: directoryReady.error, | ||
| 196 | }); | ||
| 197 | return undefined; | ||
| 198 | } | ||
| 116 | 199 | ||
| 117 | try { | 200 | const threadWriters = new Map<string, ThreadWriter>(); |
| 118 | let broken = false; | ||
| 119 | const sinks = new Map<string, RotatingFileSink>(); | ||
| 120 | const failedSegments = new Set<string>(); | 201 | const failedSegments = new Set<string>(); |
| 121 | 202 | ||
| 122 | const resolveSink = (threadSegment: string): RotatingFileSink | undefined => { | 203 | const resolveThreadWriter = (threadSegment: string): Effect.Effect<ThreadWriter | undefined> => |
| 123 | if (failedSegments.has(threadSegment)) { | 204 | Effect.gen(function* () { |
| 124 | return undefined; | 205 | if (failedSegments.has(threadSegment)) { |
| 125 | } | 206 | return undefined; |
| 126 | const existingSink = sinks.get(threadSegment); | 207 | } |
| 127 | if (existingSink) { | 208 | const existing = threadWriters.get(threadSegment); |
| 128 | return existingSink; | 209 | if (existing) { |
| 129 | } | 210 | return existing; |
| 211 | } | ||
| 130 | 212 | ||
| 131 | const threadFilePath = resolveThreadFilePath(filePath, threadSegment); | 213 | const writer = yield* makeThreadWriter({ |
| 132 | try { | 214 | filePath: path.join(path.dirname(filePath), `${threadSegment}.log`), |
| 133 | const sink = new RotatingFileSink({ | ||
| 134 | filePath: threadFilePath, | ||
| 135 | maxBytes, | 215 | maxBytes, |
| 136 | maxFiles, | 216 | maxFiles, |
| 137 | throwOnError: true, | 217 | batchWindowMs, |
| 138 | }); | 218 | streamLabel, |
| 139 | sinks.set(threadSegment, sink); | ||
| 140 | return sink; | ||
| 141 | } catch (error) { | ||
| 142 | failedSegments.add(threadSegment); | ||
| 143 | logger.warn("failed to initialize provider thread log file", { | ||
| 144 | filePath: threadFilePath, | ||
| 145 | threadSegment, | ||
| 146 | error, | ||
| 147 | }); | 219 | }); |
| 148 | return undefined; | 220 | if (!writer) { |
| 149 | } | 221 | failedSegments.add(threadSegment); |
| 150 | }; | 222 | return undefined; |
| 223 | } | ||
| 224 | |||
| 225 | threadWriters.set(threadSegment, writer); | ||
| 226 | return writer; | ||
| 227 | }); | ||
| 151 | 228 | ||
| 152 | return { | 229 | return { |
| 153 | filePath, | 230 | filePath, |
| 154 | write(record: unknown) { | 231 | write(event: unknown, threadId: ThreadId | null) { |
| 155 | if (broken) { | 232 | return Effect.gen(function* () { |
| 156 | return; | 233 | const threadSegment = resolveThreadSegment(threadId); |
| 157 | } | 234 | const message = yield* toLogMessage(event); |
| 158 | const threadSegment = resolveThreadSegment(record); | 235 | if (!message) { |
| 159 | if (!threadSegment) { | 236 | return; |
| 160 | return; | 237 | } |
| 161 | } | 238 | |
| 162 | const line = toLogLine(record); | 239 | const writer = yield* resolveThreadWriter(threadSegment); |
| 163 | if (!line) { | 240 | if (!writer) { |
| 164 | return; | 241 | return; |
| 165 | } | 242 | } |
| 166 | const sink = resolveSink(threadSegment); | 243 | |
| 167 | if (!sink) { | 244 | yield* writer.writeMessage(message); |
| 168 | return; | 245 | }); |
| 169 | } | ||
| 170 | try { | ||
| 171 | sink.write(line); | ||
| 172 | } catch (error) { | ||
| 173 | broken = true; | ||
| 174 | logger.warn("provider event log write failed", { filePath, threadSegment, error }); | ||
| 175 | } | ||
| 176 | }, | 246 | }, |
| 177 | close() { | 247 | close() { |
| 178 | sinks.clear(); | 248 | return Effect.gen(function* () { |
| 249 | for (const writer of threadWriters.values()) { | ||
| 250 | yield* writer.close(); | ||
| 251 | } | ||
| 252 | threadWriters.clear(); | ||
| 253 | }); | ||
| 179 | }, | 254 | }, |
| 180 | }; | 255 | } satisfies EventNdjsonLogger; |
| 181 | } catch (error) { | 256 | }); |
| 182 | logger.warn("failed to create provider event log file", { filePath, error }); | ||
| 183 | return undefined; | ||
| 184 | } | ||
| 185 | } | 257 | } |
apps/server/src/provider/Layers/ProviderService.ts+9-12| ... | @@ -111,7 +111,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -111,7 +111,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 111 | const canonicalEventLogger = | 111 | const canonicalEventLogger = |
| 112 | options?.canonicalEventLogger ?? | 112 | options?.canonicalEventLogger ?? |
| 113 | (options?.canonicalEventLogPath !== undefined | 113 | (options?.canonicalEventLogPath !== undefined |
| 114 | ? makeEventNdjsonLogger(options.canonicalEventLogPath) | 114 | ? yield* makeEventNdjsonLogger(options.canonicalEventLogPath, { |
| 115 | stream: "canonical", | ||
| 116 | }) | ||
| 115 | : undefined); | 117 | : undefined); |
| 116 | 118 | ||
| 117 | const registry = yield* ProviderAdapterRegistry; | 119 | const registry = yield* ProviderAdapterRegistry; |
| ... | @@ -143,22 +145,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => | ... | @@ -143,22 +145,17 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => |
| 143 | canonicalizeRuntimeEventSession(event).pipe( | 145 | canonicalizeRuntimeEventSession(event).pipe( |
| 144 | Effect.tap((canonicalEvent) => | 146 | Effect.tap((canonicalEvent) => |
| 145 | canonicalEventLogger | 147 | canonicalEventLogger |
| 146 | ? Effect.tap( | 148 | ? Effect.flatMap( |
| 147 | Effect.catch( | 149 | Effect.catch( |
| 148 | directory.getThreadId(canonicalEvent.sessionId).pipe( | 150 | directory.getThreadId(canonicalEvent.sessionId).pipe( |
| 149 | Effect.map(Option.getOrUndefined), | 151 | Effect.map((threadIdOption) => |
| 152 | Option.isSome(threadIdOption) ? threadIdOption.value : null, | ||
| 153 | ), | ||
| 150 | ), | 154 | ), |
| 151 | () => Effect.void, | 155 | () => Effect.succeed<ThreadId | null>(null), |
| 152 | ), | 156 | ), |
| 153 | (orchestrationThreadId) => | 157 | (orchestrationThreadId) => |
| 154 | Effect.sync(() => { | 158 | canonicalEventLogger.write(canonicalEvent, orchestrationThreadId), |
| 155 | canonicalEventLogger.write({ | ||
| 156 | observedAt: new Date().toISOString(), | ||
| 157 | stream: "canonical", | ||
| 158 | orchestrationThreadId: orchestrationThreadId ?? null, | ||
| 159 | event: canonicalEvent, | ||
| 160 | }); | ||
| 161 | }), | ||
| 162 | ) | 159 | ) |
| 163 | : Effect.void, | 160 | : Effect.void, |
| 164 | ), | 161 | ), |
apps/server/src/serverLayers.ts+9-3| ... | @@ -43,19 +43,25 @@ export function makeServerProviderLayer(): Layer.Layer< | ... | @@ -43,19 +43,25 @@ export function makeServerProviderLayer(): Layer.Layer< |
| 43 | return Effect.gen(function* () { | 43 | return Effect.gen(function* () { |
| 44 | const { stateDir } = yield* ServerConfig; | 44 | const { stateDir } = yield* ServerConfig; |
| 45 | const providerLogsDir = path.join(stateDir, "logs", "provider"); | 45 | const providerLogsDir = path.join(stateDir, "logs", "provider"); |
| 46 | const providerEventLogger = makeEventNdjsonLogger(path.join(providerLogsDir, "events.log")); | 46 | const providerEventLogPath = path.join(providerLogsDir, "events.log"); |
| 47 | const nativeEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | ||
| 48 | stream: "native", | ||
| 49 | }); | ||
| 50 | const canonicalEventLogger = yield* makeEventNdjsonLogger(providerEventLogPath, { | ||
| 51 | stream: "canonical", | ||
| 52 | }); | ||
| 47 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( | 53 | const providerSessionDirectoryLayer = ProviderSessionDirectoryLive.pipe( |
| 48 | Layer.provide(ProviderSessionRuntimeRepositoryLive), | 54 | Layer.provide(ProviderSessionRuntimeRepositoryLive), |
| 49 | ); | 55 | ); |
| 50 | const codexAdapterLayer = makeCodexAdapterLive( | 56 | const codexAdapterLayer = makeCodexAdapterLive( |
| 51 | providerEventLogger ? { nativeEventLogger: providerEventLogger } : undefined, | 57 | nativeEventLogger ? { nativeEventLogger } : undefined, |
| 52 | ); | 58 | ); |
| 53 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( | 59 | const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( |
| 54 | Layer.provide(codexAdapterLayer), | 60 | Layer.provide(codexAdapterLayer), |
| 55 | Layer.provideMerge(providerSessionDirectoryLayer), | 61 | Layer.provideMerge(providerSessionDirectoryLayer), |
| 56 | ); | 62 | ); |
| 57 | return makeProviderServiceLive( | 63 | return makeProviderServiceLive( |
| 58 | providerEventLogger ? { canonicalEventLogger: providerEventLogger } : undefined, | 64 | canonicalEventLogger ? { canonicalEventLogger } : undefined, |
| 59 | ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); | 65 | ).pipe(Layer.provide(adapterRegistryLayer), Layer.provide(providerSessionDirectoryLayer)); |
| 60 | }).pipe(Layer.unwrap); | 66 | }).pipe(Layer.unwrap); |
| 61 | } | 67 | } |