summaryrefslogtreecommitdiffhomepage
path: root/packages/journal-sink/src/journal-sink.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/journal-sink/src/journal-sink.test.ts')
-rw-r--r--packages/journal-sink/src/journal-sink.test.ts508
1 files changed, 254 insertions, 254 deletions
diff --git a/packages/journal-sink/src/journal-sink.test.ts b/packages/journal-sink/src/journal-sink.test.ts
index a6531c7..36e4983 100644
--- a/packages/journal-sink/src/journal-sink.test.ts
+++ b/packages/journal-sink/src/journal-sink.test.ts
@@ -9,123 +9,123 @@ import { createJournalSink, serialize } from "./journal-sink.js";
// --- Fixtures: one per LogRecord variant ---
const logRecord: LogRecord = {
- kind: "log",
- level: "info",
- msg: "hello world",
- timestamp: 1700000000000,
- extensionId: "test-ext",
- conversationId: "conv-1",
- turnId: "turn-1",
- spanId: "span-1",
- attributes: { key: "value" },
+ kind: "log",
+ level: "info",
+ msg: "hello world",
+ timestamp: 1700000000000,
+ extensionId: "test-ext",
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ spanId: "span-1",
+ attributes: { key: "value" },
};
const logRecordMinimal: LogRecord = {
- kind: "log",
- level: "debug",
- msg: "minimal",
- timestamp: 1700000000001,
- extensionId: "ext-min",
+ kind: "log",
+ level: "debug",
+ msg: "minimal",
+ timestamp: 1700000000001,
+ extensionId: "ext-min",
};
const spanOpenRecord: LogRecord = {
- kind: "span-open",
- spanId: "span-2",
- name: "provider.request",
- timestamp: 1700000000100,
- extensionId: "test-ext",
- conversationId: "conv-1",
- turnId: "turn-1",
- parentSpanId: "span-1",
- attributes: { model: "gpt-4" },
- links: [{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }],
- body: "verbatim request body",
+ kind: "span-open",
+ spanId: "span-2",
+ name: "provider.request",
+ timestamp: 1700000000100,
+ extensionId: "test-ext",
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ parentSpanId: "span-1",
+ attributes: { model: "gpt-4" },
+ links: [{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }],
+ body: "verbatim request body",
};
const spanOpenRecordMinimal: LogRecord = {
- kind: "span-open",
- spanId: "span-3",
- name: "tool.call",
- timestamp: 1700000000200,
- extensionId: "ext-tool",
+ kind: "span-open",
+ spanId: "span-3",
+ name: "tool.call",
+ timestamp: 1700000000200,
+ extensionId: "ext-tool",
};
const spanCloseRecord: LogRecord = {
- kind: "span-close",
- spanId: "span-2",
- name: "provider.request",
- timestamp: 1700000000500,
- durationMs: 400,
- status: "ok",
- extensionId: "test-ext",
- conversationId: "conv-1",
- turnId: "turn-1",
- parentSpanId: "span-1",
- attributes: { cacheHit: true },
- links: [{ spanId: "span-0" }],
+ kind: "span-close",
+ spanId: "span-2",
+ name: "provider.request",
+ timestamp: 1700000000500,
+ durationMs: 400,
+ status: "ok",
+ extensionId: "test-ext",
+ conversationId: "conv-1",
+ turnId: "turn-1",
+ parentSpanId: "span-1",
+ attributes: { cacheHit: true },
+ links: [{ spanId: "span-0" }],
};
const spanCloseRecordError: LogRecord = {
- kind: "span-close",
- spanId: "span-4",
- name: "failing-step",
- timestamp: 1700000000600,
- durationMs: 100,
- status: "error",
- extensionId: "ext-err",
+ kind: "span-close",
+ spanId: "span-4",
+ name: "failing-step",
+ timestamp: 1700000000600,
+ durationMs: 100,
+ status: "error",
+ extensionId: "ext-err",
};
// --- Pure core: serialize ---
describe("serialize", () => {
- const allRecords = [
- { name: "log (full)", record: logRecord },
- { name: "log (minimal)", record: logRecordMinimal },
- { name: "span-open (full)", record: spanOpenRecord },
- { name: "span-open (minimal)", record: spanOpenRecordMinimal },
- { name: "span-close (full)", record: spanCloseRecord },
- { name: "span-close (error)", record: spanCloseRecordError },
- ];
-
- for (const { name, record } of allRecords) {
- it(`produces exactly one NDJSON line for ${name}`, () => {
- const line = serialize(record);
- expect(line.endsWith("\n")).toBe(true);
- expect(line.endsWith("\n\n")).toBe(false);
- const parsed = JSON.parse(line);
- expect(parsed).toEqual(record);
- });
-
- it(`round-trips ${name} through JSON.parse`, () => {
- const line = serialize(record);
- const roundTripped: LogRecord = JSON.parse(line);
- expect(roundTripped).toEqual(record);
- expect(roundTripped.kind).toBe(record.kind);
- });
- }
-
- it("preserves all LogRecord variant kinds", () => {
- expect(JSON.parse(serialize(logRecord)).kind).toBe("log");
- expect(JSON.parse(serialize(spanOpenRecord)).kind).toBe("span-open");
- expect(JSON.parse(serialize(spanCloseRecord)).kind).toBe("span-close");
- });
-
- it("preserves optional fields when present", () => {
- const parsed = JSON.parse(serialize(spanOpenRecord));
- expect(parsed.body).toBe("verbatim request body");
- expect(parsed.links).toEqual([{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }]);
- expect(parsed.attributes).toEqual({ model: "gpt-4" });
- });
-
- it("omits optional fields when absent (no undefined in output)", () => {
- const parsed = JSON.parse(serialize(logRecordMinimal));
- expect(parsed.conversationId).toBeUndefined();
- expect(parsed.turnId).toBeUndefined();
- expect(parsed.spanId).toBeUndefined();
- expect(parsed.attributes).toBeUndefined();
- expect(parsed.body).toBeUndefined();
- expect("conversationId" in parsed).toBe(false);
- });
+ const allRecords = [
+ { name: "log (full)", record: logRecord },
+ { name: "log (minimal)", record: logRecordMinimal },
+ { name: "span-open (full)", record: spanOpenRecord },
+ { name: "span-open (minimal)", record: spanOpenRecordMinimal },
+ { name: "span-close (full)", record: spanCloseRecord },
+ { name: "span-close (error)", record: spanCloseRecordError },
+ ];
+
+ for (const { name, record } of allRecords) {
+ it(`produces exactly one NDJSON line for ${name}`, () => {
+ const line = serialize(record);
+ expect(line.endsWith("\n")).toBe(true);
+ expect(line.endsWith("\n\n")).toBe(false);
+ const parsed = JSON.parse(line);
+ expect(parsed).toEqual(record);
+ });
+
+ it(`round-trips ${name} through JSON.parse`, () => {
+ const line = serialize(record);
+ const roundTripped: LogRecord = JSON.parse(line);
+ expect(roundTripped).toEqual(record);
+ expect(roundTripped.kind).toBe(record.kind);
+ });
+ }
+
+ it("preserves all LogRecord variant kinds", () => {
+ expect(JSON.parse(serialize(logRecord)).kind).toBe("log");
+ expect(JSON.parse(serialize(spanOpenRecord)).kind).toBe("span-open");
+ expect(JSON.parse(serialize(spanCloseRecord)).kind).toBe("span-close");
+ });
+
+ it("preserves optional fields when present", () => {
+ const parsed = JSON.parse(serialize(spanOpenRecord));
+ expect(parsed.body).toBe("verbatim request body");
+ expect(parsed.links).toEqual([{ spanId: "span-0", turnId: "turn-0", reason: "caused-by" }]);
+ expect(parsed.attributes).toEqual({ model: "gpt-4" });
+ });
+
+ it("omits optional fields when absent (no undefined in output)", () => {
+ const parsed = JSON.parse(serialize(logRecordMinimal));
+ expect(parsed.conversationId).toBeUndefined();
+ expect(parsed.turnId).toBeUndefined();
+ expect(parsed.spanId).toBeUndefined();
+ expect(parsed.attributes).toBeUndefined();
+ expect(parsed.body).toBeUndefined();
+ expect("conversationId" in parsed).toBe(false);
+ });
});
// --- Imperative shell: createJournalSink (fs integration) ---
@@ -133,177 +133,177 @@ describe("serialize", () => {
let tmpDir: string;
beforeEach(async () => {
- tmpDir = await mkdtemp(join(tmpdir(), "journal-sink-test-"));
+ tmpDir = await mkdtemp(join(tmpdir(), "journal-sink-test-"));
});
afterEach(async () => {
- await rm(tmpDir, { recursive: true, force: true });
+ await rm(tmpDir, { recursive: true, force: true });
});
describe("createJournalSink", () => {
- it("emits records that can be read back as NDJSON", async () => {
- const path = join(tmpDir, "journal.log");
- const sink = createJournalSink({ path, fsync: "none" });
-
- const records = [logRecord, spanOpenRecord, spanCloseRecord];
- for (const r of records) {
- sink.emit(r);
- }
-
- const content = await readFile(path, "utf8");
- const lines = content.split("\n").filter((l) => l.length > 0);
- expect(lines).toHaveLength(3);
-
- for (let i = 0; i < lines.length; i++) {
- const parsed: LogRecord = JSON.parse(lines[i] ?? "");
- expect(parsed).toEqual(records[i]);
- }
- });
-
- it("warns and does NOT throw when writing to a bad path", () => {
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- const badPath = join(tmpDir, "nonexistent", "deep", "journal.log");
- const sink = createJournalSink({ path: badPath, fsync: "none" });
-
- expect(() => sink.emit(logRecord)).not.toThrow();
- expect(warnSpy).toHaveBeenCalled();
- expect(warnSpy.mock.calls[0]?.[0]).toContain("[journal-sink]");
-
- warnSpy.mockRestore();
- });
-
- it("appends to existing file on creation", async () => {
- const path = join(tmpDir, "journal.log");
- const { writeFileSync } = await import("node:fs");
- writeFileSync(path, serialize(logRecord));
-
- const sink = createJournalSink({ path, fsync: "none" });
- sink.emit(spanOpenRecord);
-
- const content = await readFile(path, "utf8");
- const lines = content.split("\n").filter((l) => l.length > 0);
- expect(lines).toHaveLength(2);
- expect(JSON.parse(lines[0] ?? "").kind).toBe("log");
- expect(JSON.parse(lines[1] ?? "").kind).toBe("span-open");
- });
-
- it("warns and drops records when fs.write throws mid-emit", () => {
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
- const brokenFs: FsOps = {
- open: () => 1,
- write: () => {
- throw new Error("disk full");
- },
- close: () => {},
- rename: () => {},
- statSize: () => 0,
- fsync: () => {},
- };
- const sink = createJournalSink({ path: join(tmpDir, "j.log"), fs: brokenFs, fsync: "none" });
-
- expect(() => sink.emit(logRecord)).not.toThrow();
- expect(warnSpy).toHaveBeenCalled();
- expect(warnSpy.mock.calls[0]?.[1]).toBeInstanceOf(Error);
-
- warnSpy.mockRestore();
- });
+ it("emits records that can be read back as NDJSON", async () => {
+ const path = join(tmpDir, "journal.log");
+ const sink = createJournalSink({ path, fsync: "none" });
+
+ const records = [logRecord, spanOpenRecord, spanCloseRecord];
+ for (const r of records) {
+ sink.emit(r);
+ }
+
+ const content = await readFile(path, "utf8");
+ const lines = content.split("\n").filter((l) => l.length > 0);
+ expect(lines).toHaveLength(3);
+
+ for (let i = 0; i < lines.length; i++) {
+ const parsed: LogRecord = JSON.parse(lines[i] ?? "");
+ expect(parsed).toEqual(records[i]);
+ }
+ });
+
+ it("warns and does NOT throw when writing to a bad path", () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const badPath = join(tmpDir, "nonexistent", "deep", "journal.log");
+ const sink = createJournalSink({ path: badPath, fsync: "none" });
+
+ expect(() => sink.emit(logRecord)).not.toThrow();
+ expect(warnSpy).toHaveBeenCalled();
+ expect(warnSpy.mock.calls[0]?.[0]).toContain("[journal-sink]");
+
+ warnSpy.mockRestore();
+ });
+
+ it("appends to existing file on creation", async () => {
+ const path = join(tmpDir, "journal.log");
+ const { writeFileSync } = await import("node:fs");
+ writeFileSync(path, serialize(logRecord));
+
+ const sink = createJournalSink({ path, fsync: "none" });
+ sink.emit(spanOpenRecord);
+
+ const content = await readFile(path, "utf8");
+ const lines = content.split("\n").filter((l) => l.length > 0);
+ expect(lines).toHaveLength(2);
+ expect(JSON.parse(lines[0] ?? "").kind).toBe("log");
+ expect(JSON.parse(lines[1] ?? "").kind).toBe("span-open");
+ });
+
+ it("warns and drops records when fs.write throws mid-emit", () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const brokenFs: FsOps = {
+ open: () => 1,
+ write: () => {
+ throw new Error("disk full");
+ },
+ close: () => {},
+ rename: () => {},
+ statSize: () => 0,
+ fsync: () => {},
+ };
+ const sink = createJournalSink({ path: join(tmpDir, "j.log"), fs: brokenFs, fsync: "none" });
+
+ expect(() => sink.emit(logRecord)).not.toThrow();
+ expect(warnSpy).toHaveBeenCalled();
+ expect(warnSpy.mock.calls[0]?.[1]).toBeInstanceOf(Error);
+
+ warnSpy.mockRestore();
+ });
});
// --- Rotation ---
describe("rotation", () => {
- it("rotates when file exceeds maxBytes", async () => {
- const path = join(tmpDir, "journal.log");
- const sink = createJournalSink({ path, maxBytes: 100, fsync: "none" });
-
- sink.emit(logRecord);
- sink.emit(logRecordMinimal);
- sink.emit(spanOpenRecord);
-
- const content = await readFile(path, "utf8");
- const lines = content.split("\n").filter((l) => l.length > 0);
- expect(lines.length).toBeGreaterThan(0);
-
- let rotatedExists = false;
- try {
- await stat(`${path}.1`);
- rotatedExists = true;
- } catch {
- // May not exist if rotation hasn't triggered yet.
- }
- expect(rotatedExists).toBe(true);
-
- const rotatedContent = await readFile(`${path}.1`, "utf8");
- const allLines = [...rotatedContent.split("\n").filter((l) => l.length > 0), ...lines];
- for (const line of allLines) {
- const parsed = JSON.parse(line);
- expect(["log", "span-open", "span-close"]).toContain(parsed.kind);
- }
- });
+ it("rotates when file exceeds maxBytes", async () => {
+ const path = join(tmpDir, "journal.log");
+ const sink = createJournalSink({ path, maxBytes: 100, fsync: "none" });
+
+ sink.emit(logRecord);
+ sink.emit(logRecordMinimal);
+ sink.emit(spanOpenRecord);
+
+ const content = await readFile(path, "utf8");
+ const lines = content.split("\n").filter((l) => l.length > 0);
+ expect(lines.length).toBeGreaterThan(0);
+
+ let rotatedExists = false;
+ try {
+ await stat(`${path}.1`);
+ rotatedExists = true;
+ } catch {
+ // May not exist if rotation hasn't triggered yet.
+ }
+ expect(rotatedExists).toBe(true);
+
+ const rotatedContent = await readFile(`${path}.1`, "utf8");
+ const allLines = [...rotatedContent.split("\n").filter((l) => l.length > 0), ...lines];
+ for (const line of allLines) {
+ const parsed = JSON.parse(line);
+ expect(["log", "span-open", "span-close"]).toContain(parsed.kind);
+ }
+ });
});
// --- Fsync ---
describe("fsync", () => {
- it("calls fsync periodically when mode is periodic", () => {
- let fsyncCalls = 0;
- let currentTime = 0;
- const mockFs: FsOps = {
- open: () => 1,
- write: () => {},
- close: () => {},
- rename: () => {},
- statSize: () => 0,
- fsync: () => {
- fsyncCalls++;
- },
- };
- const mockClock: ClockOps = {
- now: () => currentTime,
- };
- const sink = createJournalSink({
- path: join(tmpDir, "j.log"),
- fs: mockFs,
- clock: mockClock,
- fsync: "periodic",
- });
-
- sink.emit(logRecord);
- expect(fsyncCalls).toBe(0);
-
- currentTime = 6_000;
- sink.emit(logRecordMinimal);
- expect(fsyncCalls).toBe(1);
-
- sink.emit(spanOpenRecord);
- expect(fsyncCalls).toBe(1);
-
- currentTime = 12_000;
- sink.emit(spanCloseRecord);
- expect(fsyncCalls).toBe(2);
- });
-
- it("never calls fsync when mode is none", () => {
- let fsyncCalls = 0;
- const mockFs: FsOps = {
- open: () => 1,
- write: () => {},
- close: () => {},
- rename: () => {},
- statSize: () => 0,
- fsync: () => {
- fsyncCalls++;
- },
- };
- const sink = createJournalSink({
- path: join(tmpDir, "j.log"),
- fs: mockFs,
- fsync: "none",
- });
-
- sink.emit(logRecord);
- sink.emit(logRecord);
- sink.emit(logRecord);
- expect(fsyncCalls).toBe(0);
- });
+ it("calls fsync periodically when mode is periodic", () => {
+ let fsyncCalls = 0;
+ let currentTime = 0;
+ const mockFs: FsOps = {
+ open: () => 1,
+ write: () => {},
+ close: () => {},
+ rename: () => {},
+ statSize: () => 0,
+ fsync: () => {
+ fsyncCalls++;
+ },
+ };
+ const mockClock: ClockOps = {
+ now: () => currentTime,
+ };
+ const sink = createJournalSink({
+ path: join(tmpDir, "j.log"),
+ fs: mockFs,
+ clock: mockClock,
+ fsync: "periodic",
+ });
+
+ sink.emit(logRecord);
+ expect(fsyncCalls).toBe(0);
+
+ currentTime = 6_000;
+ sink.emit(logRecordMinimal);
+ expect(fsyncCalls).toBe(1);
+
+ sink.emit(spanOpenRecord);
+ expect(fsyncCalls).toBe(1);
+
+ currentTime = 12_000;
+ sink.emit(spanCloseRecord);
+ expect(fsyncCalls).toBe(2);
+ });
+
+ it("never calls fsync when mode is none", () => {
+ let fsyncCalls = 0;
+ const mockFs: FsOps = {
+ open: () => 1,
+ write: () => {},
+ close: () => {},
+ rename: () => {},
+ statSize: () => 0,
+ fsync: () => {
+ fsyncCalls++;
+ },
+ };
+ const sink = createJournalSink({
+ path: join(tmpDir, "j.log"),
+ fs: mockFs,
+ fsync: "none",
+ });
+
+ sink.emit(logRecord);
+ sink.emit(logRecord);
+ sink.emit(logRecord);
+ expect(fsyncCalls).toBe(0);
+ });
});