| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as DateTime from "effect/DateTime"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as FileSystem from "effect/FileSystem"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Logger from "effect/Logger"; |
| 7 | import * as Option from "effect/Option"; |
| 8 | import * as PlatformError from "effect/PlatformError"; |
| 9 | import * as References from "effect/References"; |
| 10 | import * as Stream from "effect/Stream"; |
| 11 | |
| 12 | import * as TraceDiagnostics from "./TraceDiagnostics.ts"; |
| 13 | |
| 14 | function ns(ms: number): string { |
| 15 | return String(BigInt(ms) * 1_000_000n); |
| 16 | } |
| 17 | |
| 18 | function 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 | |
| 44 | const traceFilePath = "/tmp/server.trace.ndjson"; |
| 45 | const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z"); |
| 46 | |
| 47 | /** Aggregates whole lines in memory, the way diagnostics read traces before streaming. */ |
| 48 | function 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. */ |
| 59 | function 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 | |
| 65 | describe("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 | }); |