1import { assert, describe, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Logger from "effect/Logger";
7import * as Option from "effect/Option";
8import * as PlatformError from "effect/PlatformError";
9import * as References from "effect/References";
10import * as Stream from "effect/Stream";
11
12import * as TraceDiagnostics from "./TraceDiagnostics.ts";
13
14function ns(ms: number): string {
15 return String(BigInt(ms) * 1_000_000n);
16}
17
18function record(input: {
19 readonly name: string;
20 readonly traceId: string;
21 readonly spanId: string;
22 readonly startMs: number;
23 readonly durationMs: number;
24 readonly exit?: { readonly _tag: "Success" | "Failure" | "Interrupted"; readonly cause?: string };
25 readonly events?: ReadonlyArray<unknown>;
26}) {
27 return JSON.stringify({
28 type: "effect-span",
29 name: input.name,
30 traceId: input.traceId,
31 spanId: input.spanId,
32 sampled: true,
33 kind: "internal",
34 startTimeUnixNano: ns(input.startMs),
35 endTimeUnixNano: ns(input.startMs + input.durationMs),
36 durationMs: input.durationMs,
37 attributes: {},
38 events: input.events ?? [],
39 links: [],
40 exit: input.exit ?? { _tag: "Success" },
41 });
42}
43
44const traceFilePath = "/tmp/server.trace.ndjson";
45const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
46
47/** Aggregates whole lines in memory, the way diagnostics read traces before streaming. */
48function aggregateLines(lines: ReadonlyArray<string>) {
49 const aggregator = TraceDiagnostics.makeTraceDiagnosticsAggregator();
50 lines.forEach(aggregator.addLine);
51 return aggregator.finish({
52 traceFilePath,
53 scannedFilePaths: TraceDiagnostics.toRotatedTracePaths(traceFilePath, 1),
54 readAt,
55 });
56}
57
58/** Reads the trace file and one rotated backup through a fake file system. */
59function readTraces(fileSystem: Partial<FileSystem.FileSystem>) {
60 return TraceDiagnostics.readTraceDiagnostics({ traceFilePath, maxFiles: 1, readAt }).pipe(
61 Effect.provide(TraceDiagnostics.layer.pipe(Layer.provide(FileSystem.layerNoop(fileSystem)))),
62 );
63}
64
65describe("TraceDiagnostics", () => {
66 it.effect("aggregates failures, slow spans, log levels, and parse errors", () =>
67 Effect.sync(() => {
68 const diagnostics = aggregateLines([
69 record({
70 name: "server.getConfig",
71 traceId: "trace-a",
72 spanId: "span-a",
73 startMs: 1_000,
74 durationMs: 50,
75 }),
76 "not-json",
77 record({
78 name: "orchestration.dispatch",
79 traceId: "trace-b",
80 spanId: "span-b",
81 startMs: 2_000,
82 durationMs: 1_500,
83 exit: { _tag: "Failure", cause: "Provider crashed" },
84 events: [
85 {
86 name: "provider failed",
87 timeUnixNano: ns(3_400),
88 attributes: { "effect.logLevel": "Error" },
89 },
90 ],
91 }),
92 record({
93 name: "orchestration.dispatch",
94 traceId: "trace-c",
95 spanId: "span-c",
96 startMs: 4_000,
97 durationMs: 250,
98 exit: { _tag: "Failure", cause: "Provider crashed" },
99 }),
100 record({
101 name: "git.status",
102 traceId: "trace-d",
103 spanId: "span-d",
104 startMs: 5_000,
105 durationMs: 25,
106 exit: { _tag: "Interrupted", cause: "Interrupted" },
107 events: [
108 {
109 name: "status delayed",
110 timeUnixNano: ns(5_010),
111 attributes: { "effect.logLevel": "Warning" },
112 },
113 ],
114 }),
115 ]);
116
117 assert.equal(diagnostics.recordCount, 4);
118 assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z");
119 assert.equal(
120 Option.match(diagnostics.firstSpanAt, {
121 onNone: () => null,
122 onSome: DateTime.formatIso,
123 }),
124 "1970-01-01T00:00:01.000Z",
125 );
126 assert.equal(
127 Option.match(diagnostics.lastSpanAt, {
128 onNone: () => null,
129 onSome: DateTime.formatIso,
130 }),
131 "1970-01-01T00:00:05.025Z",
132 );
133 assert.equal(diagnostics.parseErrorCount, 1);
134 assert.equal(diagnostics.failureCount, 2);
135 assert.equal(diagnostics.interruptionCount, 1);
136 assert.equal(diagnostics.slowSpanCount, 1);
137 assert.equal(diagnostics.logLevelCounts.Error, 1);
138 assert.equal(diagnostics.logLevelCounts.Warning, 1);
139 assert.equal(diagnostics.commonFailures[0]?.name, "orchestration.dispatch");
140 assert.equal(diagnostics.commonFailures[0]?.count, 2);
141 assert.equal(diagnostics.latestFailures[0]?.traceId, "trace-c");
142 assert.equal(diagnostics.slowestSpans[0]?.traceId, "trace-b");
143 assert.equal(diagnostics.latestWarningAndErrorLogs[0]?.message, "status delayed");
144 assert.equal(diagnostics.topSpansByCount[0]?.name, "orchestration.dispatch");
145 }),
146 );
147
148 it.effect("returns a not-found diagnostic when no files are available", () =>
149 Effect.gen(function* () {
150 const diagnostics = yield* readTraces({});
151
152 assert.equal(diagnostics.recordCount, 0);
153 assert.equal(Option.getOrUndefined(diagnostics.error)?.kind, "trace-file-not-found");
154 }),
155 );
156
157 it.effect("streams rotated files into the same result as reading them whole", () =>
158 Effect.gen(function* () {
159 // CRLF and LF endings plus multi-byte text, served one byte per chunk so
160 // chunks split lines, line endings, and characters.
161 const files = new Map([
162 [
163 `${traceFilePath}.1`,
164 [
165 record({
166 name: "server.getConfig",
167 traceId: "trace-a",
168 spanId: "span-a",
169 startMs: 1_000,
170 durationMs: 50,
171 }),
172 "not-json",
173 record({
174 name: "orchestration.dispatch",
175 traceId: "trace-b",
176 spanId: "span-b",
177 startMs: 2_000,
178 durationMs: 1_500,
179 exit: { _tag: "Failure", cause: "Provider crashed: café 🔥" },
180 }),
181 "",
182 ].join("\r\n"),
183 ],
184 [
185 traceFilePath,
186 [
187 record({
188 name: "git.status",
189 traceId: "trace-c",
190 spanId: "span-c",
191 startMs: 3_000,
192 durationMs: 25,
193 exit: { _tag: "Interrupted", cause: "Interrupted" },
194 events: [
195 {
196 name: "status delayed ⏳",
197 timeUnixNano: ns(3_010),
198 attributes: { "effect.logLevel": "Warning" },
199 },
200 ],
201 }),
202 "",
203 record({
204 name: "orchestration.dispatch",
205 traceId: "trace-d",
206 spanId: "span-d",
207 startMs: 4_000,
208 durationMs: 250,
209 exit: { _tag: "Failure", cause: "Provider crashed: café 🔥" },
210 }),
211 ].join("\n"),
212 ],
213 ]);
214 const encoder = new TextEncoder();
215
216 const diagnostics = yield* readTraces({
217 stream: (path) =>
218 Stream.fromIterable(
219 Array.from(encoder.encode(files.get(path)), (byte) => Uint8Array.of(byte)),
220 ),
221 });
222
223 assert.equal(diagnostics.recordCount, 4);
224 assert.deepStrictEqual(
225 diagnostics,
226 aggregateLines([...files.values()].flatMap((text) => text.split(/\r?\n/))),
227 );
228 }),
229 );
230
231 it.effect("preserves full failure causes and log messages", () =>
232 Effect.sync(() => {
233 const longCause = `VcsProcessSpawnError: ${"missing executable ".repeat(80)}`.trim();
234 const longMessage = `provider warning: ${"retrying command ".repeat(80)}`.trim();
235 const diagnostics = aggregateLines([
236 record({
237 name: "VcsProcess.run",
238 traceId: "trace-long",
239 spanId: "span-long",
240 startMs: 1_000,
241 durationMs: 25,
242 exit: { _tag: "Failure", cause: longCause },
243 events: [
244 {
245 name: longMessage,
246 timeUnixNano: ns(1_010),
247 attributes: { "effect.logLevel": "Warning" },
248 },
249 ],
250 }),
251 ]);
252
253 assert.equal(diagnostics.latestFailures[0]?.cause, longCause);
254 assert.equal(diagnostics.commonFailures[0]?.cause, longCause);
255 assert.equal(diagnostics.latestWarningAndErrorLogs[0]?.message, longMessage);
256 }),
257 );
258
259 it.effect("keeps loaded trace data when one rotated trace file fails to read", () =>
260 Effect.gen(function* () {
261 const readFailure = PlatformError.systemError({
262 _tag: "PermissionDenied",
263 module: "FileSystem",
264 method: "open",
265 description: "permission denied",
266 pathOrDescriptor: `${traceFilePath}.1`,
267 });
268 const logAnnotations: Array<Record<string, unknown>> = [];
269 const logger = Logger.make<unknown, void>((options) => {
270 logAnnotations.push({ ...options.fiber.getRef(References.CurrentLogAnnotations) });
271 });
272
273 const diagnostics = yield* readTraces({
274 stream: (path) =>
275 path === `${traceFilePath}.1`
276 ? Stream.fail(readFailure)
277 : Stream.make(
278 new TextEncoder().encode(
279 record({
280 name: "server.getConfig",
281 traceId: "trace-a",
282 spanId: "span-a",
283 startMs: 1_000,
284 durationMs: 50,
285 }),
286 ),
287 ),
288 }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false })));
289
290 assert.equal(diagnostics.recordCount, 1);
291 assert.equal(
292 Option.getOrElse(diagnostics.partialFailure, () => false),
293 true,
294 );
295 assert.deepStrictEqual(Option.getOrUndefined(diagnostics.error), {
296 kind: "trace-file-read-failed",
297 message: `Failed to read local trace file '${traceFilePath}.1'.`,
298 });
299 assert.deepStrictEqual(diagnostics.scannedFilePaths, [`${traceFilePath}.1`, traceFilePath]);
300
301 const failureLog = logAnnotations.find(
302 (annotations) => annotations.traceFilePath === `${traceFilePath}.1`,
303 );
304 assert.exists(failureLog);
305 assert.deepStrictEqual(failureLog, {
306 traceFilePath: `${traceFilePath}.1`,
307 errorTag: "TraceFileReadError",
308 causeTag: "PermissionDenied",
309 });
310 }),
311 );
312
313 it.effect("keeps only the top spans, failures, and warning logs from large inputs", () =>
314 Effect.sync(() => {
315 // Shuffled, so some older records arrive after the lists are full.
316 const indexes = Array.from({ length: 30 }, (_, step) => (step * 7) % 30);
317 const diagnostics = aggregateLines(
318 indexes.map((index) =>
319 record({
320 name: `span-${index}`,
321 traceId: `trace-${index}`,
322 spanId: `span-${index}`,
323 startMs: index * 1_000,
324 durationMs: index,
325 exit: { _tag: "Failure", cause: "Provider crashed" },
326 events: [
327 {
328 name: `warning ${index}`,
329 timeUnixNano: ns(index * 1_000),
330 attributes: { "effect.logLevel": "Warning" },
331 },
332 ],
333 }),
334 ),
335 );
336 const newestTwenty = Array.from({ length: 20 }, (_, rank) => `trace-${29 - rank}`);
337
338 assert.equal(diagnostics.recordCount, 30);
339 assert.deepStrictEqual(
340 diagnostics.slowestSpans.map((span) => span.durationMs),
341 [29, 28, 27, 26, 25, 24, 23, 22, 21, 20],
342 );
343 assert.deepStrictEqual(
344 diagnostics.latestFailures.map((failure) => failure.traceId),
345 newestTwenty,
346 );
347 assert.deepStrictEqual(
348 diagnostics.latestWarningAndErrorLogs.map((log) => log.traceId),
349 newestTwenty,
350 );
351 }),
352 );
353});