Release Log Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 08:33:59 +02:00
parent 84a9978aa8
commit f209bf78ee
27 changed files with 1621 additions and 106 deletions
+168
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import {
ScanCancelledError,
analyzeLogSources,
detectLogFormat,
exportRecords,
exportLogBlob,
exportOtlpJson,
filterRecords,
parseLogLine,
redactRecords,
@@ -34,6 +37,17 @@ function streamingBlob(source: string, chunkSize = 7): Blob {
} as unknown as Blob;
}
async function readBlobText(blob: Blob): Promise<string> {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result ?? "")));
reader.addEventListener("error", () =>
reject(reader.error ?? new Error("Could not read test Blob.")),
);
reader.readAsText(blob);
});
}
describe("incremental scanning", () => {
it("uses Blob.stream and incremental TextDecoder across UTF-8 chunk boundaries", async () => {
const source = [
@@ -85,6 +99,29 @@ describe("incremental scanning", () => {
expect(report.timeline.length).toBeGreaterThan(0);
});
it("assembles Java, .NET, and Python continuation lines with provenance", async () => {
const source = [
"2026-09-01T10:00:00Z ERROR request failed",
" at service.Handler.run(Handler.java:42)",
"Caused by: java.io.IOException: disk",
"2026-09-01T10:00:01Z INFO recovered",
"Traceback (most recent call last):",
' File "worker.py", line 4, in run',
" raise RuntimeError()",
"RuntimeError",
].join("\n");
const report = await scanLogBlob(streamingBlob(source), {
format: "plain",
multiline: "auto",
});
expect(report.totalLines).toBe(8);
expect(report.preview[0]).toMatchObject({ line: 1, lineEnd: 3 });
expect(report.preview[0]?.message).toContain("Caused by");
expect(report.preview[1]).toMatchObject({ line: 4, lineEnd: 7 });
expect(report.preview[1]?.message).toContain("worker.py");
expect(report.preview[2]).toMatchObject({ line: 8 });
});
it("supports cancellation without returning a partial replacement report", async () => {
const controller = new AbortController();
controller.abort();
@@ -259,4 +296,135 @@ describe("filter, redact, and export", () => {
).toMatchObject({ line: 1, parser: "jsonl" });
expect(exportRecords(records, "text").value).toContain("ada@example.test");
});
it("performs a complete filtered and redacted second streaming pass", async () => {
const source = Array.from({ length: 250 }, (_unused, index) =>
JSON.stringify({
level: index % 2 ? "info" : "error",
service: "api",
message: `row ${index} from ada@example.test`,
}),
).join("\n");
const progress: number[] = [];
const result = await exportLogBlob(
streamingBlob(source, 31),
{
format: "ndjson",
inputFormat: "jsonl",
filters: { level: "error" },
redaction: options,
},
(value) => progress.push(value.bytesRead),
);
expect(result.rows).toBe(125);
expect(result.physicalLines).toBe(250);
expect(result.blob).toBeDefined();
const output = await readBlobText(result.blob!);
expect(output).not.toContain("ada@example.test");
expect(output.trim().split("\n")).toHaveLength(125);
expect(progress.at(-1)).toBe(source.length);
});
});
describe("multi-file temporal and trace analysis", () => {
const traceId = "0123456789abcdef0123456789abcdef";
const rootSpan = "0123456789abcdef";
const childSpan = "fedcba9876543210";
const apiRecords = [
parseLogLine(
JSON.stringify({
timestamp: "2026-09-01T10:00:00.000Z",
level: "info",
service: "api",
message: "start",
trace_id: traceId,
span_id: rootSpan,
}),
1,
"jsonl",
),
parseLogLine(
JSON.stringify({
timestamp: "2026-09-01T09:59:59.900Z",
level: "error",
service: "api",
message: "late arrival",
trace_id: traceId,
span_id: rootSpan,
}),
2,
"jsonl",
),
];
const workerRecords = [
parseLogLine(
JSON.stringify({
timestamp: "2026-09-01T10:00:00.250Z",
level: "info",
service: "worker",
message: "child",
traceId,
spanId: childSpan,
parentSpanId: rootSpan,
}),
1,
"jsonl",
),
];
it("merges time order and correlates trace/span evidence across files", () => {
const analysis = analyzeLogSources([
{ filename: "api.log", records: apiRecords },
{ filename: "worker.log", records: workerRecords },
]);
expect(analysis).toMatchObject({
files: 2,
inputRecords: 3,
undatedEvents: 0,
});
expect(analysis.events[0]).toMatchObject({
filename: "api.log",
line: 2,
traceId,
});
expect(analysis.groups[0]).toMatchObject({
kind: "trace",
id: traceId,
events: 3,
errors: 1,
spanCount: 2,
unresolvedParentSpanIds: [],
repeatedSpanIds: [rootSpan],
});
expect(
analysis.sourceTiming.find((item) => item.filename === "api.log")
?.backwardJumps,
).toBe(1);
});
it("maps retained records to bounded OTLP/JSON without inventing timestamps", () => {
const value = exportOtlpJson([
{ filename: "api.log", records: apiRecords },
{
filename: "undated.log",
records: [parseLogLine("INFO no clock", 1, "plain")],
},
]);
const parsed = JSON.parse(value) as {
resourceLogs: Array<{
scopeLogs: Array<{
logRecords: Array<Record<string, unknown>>;
}>;
}>;
};
const first = parsed.resourceLogs[0]!.scopeLogs[0]!.logRecords[0]!;
expect(first).toMatchObject({
traceId,
spanId: rootSpan,
severityNumber: 9,
timeUnixNano: "1788256800000000000",
});
const undated = parsed.resourceLogs[1]!.scopeLogs[0]!.logRecords[0]!;
expect(undated).not.toHaveProperty("timeUnixNano");
});
});