import type { ServerTraceDiagnosticsErrorKind, ServerTraceDiagnosticsFailureSummary, ServerTraceDiagnosticsLogEvent, ServerTraceDiagnosticsRecentFailure, ServerTraceDiagnosticsResult, ServerTraceDiagnosticsSpanOccurrence, ServerTraceDiagnosticsSpanSummary, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; export interface TraceDiagnosticsOptions { readonly traceFilePath: string; readonly maxFiles: number; readonly slowSpanThresholdMs?: number; readonly readAt?: DateTime.Utc; } export interface TraceDiagnosticsShape { readonly read: (options: TraceDiagnosticsOptions) => Effect.Effect; } export class TraceDiagnostics extends Context.Service()( "t3/diagnostics/TraceDiagnostics", ) {} interface TraceDiagnosticsInput { readonly traceFilePath: string; readonly files: ReadonlyArray<{ readonly path: string; readonly text: string }>; readonly scannedFilePaths?: ReadonlyArray; readonly slowSpanThresholdMs?: number; readonly readAt: DateTime.Utc; readonly error?: TraceDiagnosticsErrorSummary; readonly partialFailure?: boolean; } interface TraceDiagnosticsErrorSummary { readonly kind: ServerTraceDiagnosticsErrorKind; readonly message: string; } const DEFAULT_SLOW_SPAN_THRESHOLD_MS = 1_000; const TOP_LIMIT = 10; const RECENT_LIMIT = 20; const TraceExitSchema = Schema.Struct({ _tag: Schema.optional(Schema.Unknown), cause: Schema.optional(Schema.Unknown), }); type TraceExit = typeof TraceExitSchema.Type; const decodeTraceExitOption = Schema.decodeUnknownOption(TraceExitSchema); const TraceEventSchema = Schema.Struct({ name: Schema.optional(Schema.Unknown), timeUnixNano: Schema.optional(Schema.Unknown), attributes: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }); const decodeTraceEventOption = Schema.decodeUnknownOption(TraceEventSchema); const TraceRecordJsonSchema = Schema.fromJsonString( Schema.Struct({ name: Schema.optional(Schema.Unknown), traceId: Schema.optional(Schema.Unknown), spanId: Schema.optional(Schema.Unknown), startTimeUnixNano: Schema.optional(Schema.Unknown), endTimeUnixNano: Schema.optional(Schema.Unknown), durationMs: Schema.optional(Schema.Unknown), exit: Schema.optional(Schema.Unknown), events: Schema.optional(Schema.Array(Schema.Unknown)), }), ); const decodeTraceRecordJsonOption = Schema.decodeUnknownOption(TraceRecordJsonSchema); function toRotatedTracePaths(traceFilePath: string, maxFiles: number): ReadonlyArray { const backupCount = Math.max(0, Math.floor(maxFiles)); const backups = Array.from( { length: backupCount }, (_, index) => `${traceFilePath}.${backupCount - index}`, ); return [...backups, traceFilePath]; } function toStringOption(value: unknown): Option.Option { return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none(); } function toNumberOption(value: unknown): Option.Option { return typeof value === "number" && Number.isFinite(value) ? Option.some(value) : Option.none(); } function unixNanoToDateTime(value: unknown): Option.Option { const text = toStringOption(value); if (Option.isNone(text)) return Option.none(); try { const millis = Number(BigInt(text.value) / 1_000_000n); return DateTime.make(millis); } catch { return Option.none(); } } function decodeTraceExit(exit: unknown): Option.Option { return decodeTraceExitOption(exit); } function readExitTag(exit: unknown): Option.Option { return Option.flatMap(decodeTraceExit(exit), (parsedExit) => toStringOption(parsedExit._tag)); } function readExitCause(exit: unknown): string { return Option.getOrElse( Option.flatMap(decodeTraceExit(exit), (parsedExit) => toStringOption(parsedExit.cause)), () => "Failure", ).trim(); } function minDateTimeOption( current: Option.Option, candidate: Option.Option, ): Option.Option { if (Option.isNone(candidate)) return current; if (Option.isNone(current)) return candidate; return DateTime.isLessThan(candidate.value, current.value) ? candidate : current; } function maxDateTime( current: Option.Option, candidate: DateTime.Utc, ): Option.Option { if (Option.isNone(current)) return Option.some(candidate); return DateTime.isGreaterThan(candidate, current.value) ? Option.some(candidate) : current; } function makeEmptyDiagnostics(input: { readonly traceFilePath: string; readonly scannedFilePaths: ReadonlyArray; readonly readAt: DateTime.Utc; readonly slowSpanThresholdMs: number; readonly error?: TraceDiagnosticsErrorSummary; readonly partialFailure?: boolean; }): ServerTraceDiagnosticsResult { return { traceFilePath: input.traceFilePath, scannedFilePaths: [...input.scannedFilePaths], readAt: input.readAt, recordCount: 0, parseErrorCount: 0, firstSpanAt: Option.none(), lastSpanAt: Option.none(), failureCount: 0, interruptionCount: 0, slowSpanThresholdMs: input.slowSpanThresholdMs, slowSpanCount: 0, logLevelCounts: {}, topSpansByCount: [], slowestSpans: [], commonFailures: [], latestFailures: [], latestWarningAndErrorLogs: [], partialFailure: input.partialFailure ? Option.some(true) : Option.none(), error: Option.fromNullishOr(input.error), }; } function isNotFoundError(error: PlatformError.PlatformError): boolean { return error.reason._tag === "NotFound"; } function platformErrorMessage(error: PlatformError.PlatformError): string { return error.message || String(error); } function insertBoundedSlowestSpan( slowestSpans: ServerTraceDiagnosticsSpanOccurrence[], span: ServerTraceDiagnosticsSpanOccurrence, ): void { if ( slowestSpans.length >= TOP_LIMIT && span.durationMs <= slowestSpans[slowestSpans.length - 1]!.durationMs ) { return; } slowestSpans.push(span); slowestSpans.sort((left, right) => right.durationMs - left.durationMs); if (slowestSpans.length > TOP_LIMIT) { slowestSpans.length = TOP_LIMIT; } } export function aggregateTraceDiagnostics( input: TraceDiagnosticsInput, ): ServerTraceDiagnosticsResult { const readAt = input.readAt; const slowSpanThresholdMs = input.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const scannedFilePaths = input.scannedFilePaths ?? input.files.map((file) => file.path); if (input.files.length === 0) { return makeEmptyDiagnostics({ traceFilePath: input.traceFilePath, scannedFilePaths, readAt, slowSpanThresholdMs, error: input.error ?? { kind: "trace-file-not-found", message: "No local trace files were found.", }, ...(input.partialFailure ? { partialFailure: true } : {}), }); } let parseErrorCount = 0; let recordCount = 0; let failureCount = 0; let interruptionCount = 0; let slowSpanCount = 0; let firstSpanAt = Option.none(); let lastSpanAt = Option.none(); const spansByName = new Map< string, { count: number; failureCount: number; totalDurationMs: number; maxDurationMs: number } >(); const failuresByKey = new Map(); const latestFailures: ServerTraceDiagnosticsRecentFailure[] = []; const slowestSpans: ServerTraceDiagnosticsSpanOccurrence[] = []; const latestWarningAndErrorLogs: ServerTraceDiagnosticsLogEvent[] = []; const logLevelCounts: Record = {}; for (const file of input.files) { const lines = file.text.split(/\r?\n/); for (const line of lines) { if (line.trim().length === 0) continue; const decodedRecord = decodeTraceRecordJsonOption(line); if (Option.isNone(decodedRecord)) { parseErrorCount += 1; continue; } const parsed = decodedRecord.value; const nameOption = toStringOption(parsed.name); const traceIdOption = toStringOption(parsed.traceId); const spanIdOption = toStringOption(parsed.spanId); const durationMsOption = toNumberOption(parsed.durationMs); const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano); const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano); if ( Option.isNone(nameOption) || Option.isNone(traceIdOption) || Option.isNone(spanIdOption) || Option.isNone(durationMsOption) || Option.isNone(endedAt) ) { parseErrorCount += 1; continue; } const name = nameOption.value; const traceId = traceIdOption.value; const spanId = spanIdOption.value; const durationMs = durationMsOption.value; recordCount += 1; firstSpanAt = minDateTimeOption(firstSpanAt, startedAt); lastSpanAt = maxDateTime(lastSpanAt, endedAt.value); const exitTag = readExitTag(parsed.exit); const isFailure = Option.contains(exitTag, "Failure"); const isInterrupted = Option.contains(exitTag, "Interrupted"); if (isFailure) failureCount += 1; if (isInterrupted) interruptionCount += 1; const spanSummary = spansByName.get(name) ?? { count: 0, failureCount: 0, totalDurationMs: 0, maxDurationMs: 0, }; spanSummary.count += 1; spanSummary.totalDurationMs += durationMs; spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, durationMs); if (isFailure) spanSummary.failureCount += 1; spansByName.set(name, spanSummary); const spanItem = { name, durationMs, endedAt: endedAt.value, traceId, spanId }; if (durationMs >= slowSpanThresholdMs) { slowSpanCount += 1; } insertBoundedSlowestSpan(slowestSpans, spanItem); if (isFailure) { const cause = readExitCause(parsed.exit); latestFailures.push({ ...spanItem, cause }); const failureKey = `${name}\0${cause}`; const existing = failuresByKey.get(failureKey); const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt.value, existing.lastSeenAt); failuresByKey.set(failureKey, { name, cause, count: (existing?.count ?? 0) + 1, lastSeenAt: isLatestFailure ? endedAt.value : existing!.lastSeenAt, traceId: isLatestFailure ? traceId : existing!.traceId, spanId: isLatestFailure ? spanId : existing!.spanId, }); } if (Array.isArray(parsed.events)) { for (const rawEvent of parsed.events) { const event = decodeTraceEventOption(rawEvent); if (Option.isNone(event)) continue; const attributes = Option.getOrElse( Option.fromUndefinedOr(event.value.attributes), () => ({}) as Readonly>, ); const levelOption = toStringOption(attributes["effect.logLevel"]); if (Option.isNone(levelOption)) continue; const level = levelOption.value; logLevelCounts[level] = (logLevelCounts[level] ?? 0) + 1; const normalizedLevel = level.toLowerCase(); if ( normalizedLevel !== "warning" && normalizedLevel !== "warn" && normalizedLevel !== "error" && normalizedLevel !== "fatal" ) { continue; } const seenAt = Option.getOrElse( unixNanoToDateTime(event.value.timeUnixNano), () => endedAt.value, ); const message = Option.getOrElse( Option.map(toStringOption(event.value.name), (name) => name.trim()), () => "Log event", ); latestWarningAndErrorLogs.push({ spanName: name, level, message, seenAt, traceId, spanId, }); } } } } const topSpansByCount: ServerTraceDiagnosticsSpanSummary[] = [...spansByName.entries()] .map(([name, span]) => ({ name, count: span.count, failureCount: span.failureCount, totalDurationMs: span.totalDurationMs, averageDurationMs: span.count > 0 ? span.totalDurationMs / span.count : 0, maxDurationMs: span.maxDurationMs, })) .toSorted((left, right) => right.count - left.count || right.maxDurationMs - left.maxDurationMs) .slice(0, TOP_LIMIT); return { traceFilePath: input.traceFilePath, scannedFilePaths, readAt, recordCount, parseErrorCount, firstSpanAt, lastSpanAt, failureCount, interruptionCount, slowSpanThresholdMs, slowSpanCount, logLevelCounts, topSpansByCount, slowestSpans, commonFailures: [...failuresByKey.values()] .toSorted( (left, right) => right.count - left.count || DateTime.toEpochMillis(right.lastSeenAt) - DateTime.toEpochMillis(left.lastSeenAt), ) .slice(0, TOP_LIMIT), latestFailures: latestFailures .toSorted( (left, right) => DateTime.toEpochMillis(right.endedAt) - DateTime.toEpochMillis(left.endedAt), ) .slice(0, RECENT_LIMIT), latestWarningAndErrorLogs: latestWarningAndErrorLogs .toSorted( (left, right) => DateTime.toEpochMillis(right.seenAt) - DateTime.toEpochMillis(left.seenAt), ) .slice(0, RECENT_LIMIT), partialFailure: input.partialFailure ? Option.some(true) : Option.none(), error: Option.fromNullishOr(input.error), }; } type TraceFileReadResult = | { readonly _tag: "Loaded"; readonly path: string; readonly text: string } | { readonly _tag: "Missing"; readonly path: string } | { readonly _tag: "Failed"; readonly path: string; readonly message: string }; function readTraceFile( fileSystem: FileSystem.FileSystem, path: string, ): Effect.Effect { return fileSystem.readFileString(path).pipe( Effect.map((text) => ({ _tag: "Loaded" as const, path, text })), Effect.catch((error: PlatformError.PlatformError) => Effect.succeed( isNotFoundError(error) ? { _tag: "Missing" as const, path } : { _tag: "Failed" as const, path, message: platformErrorMessage(error) }, ), ), ); } export const make = Effect.fn("makeTraceDiagnostics")(function* () { const fileSystem = yield* FileSystem.FileSystem; const read: TraceDiagnosticsShape["read"] = Effect.fn("TraceDiagnostics.read")( function* (options) { const readAt = options.readAt ?? (yield* DateTime.now); const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); const results = yield* Effect.all( paths.map((path) => readTraceFile(fileSystem, path)), { concurrency: 1, }, ); const files = results.flatMap((result) => result._tag === "Loaded" ? [{ path: result.path, text: result.text }] : [], ); const readFailure = results.find((result) => result._tag === "Failed"); const readFailureError = readFailure ? ({ kind: "trace-file-read-failed", message: readFailure.message.trim() || `Failed to read ${readFailure.path}.`, } satisfies TraceDiagnosticsErrorSummary) : undefined; if (files.length === 0) { return makeEmptyDiagnostics({ traceFilePath: options.traceFilePath, scannedFilePaths: paths, readAt, slowSpanThresholdMs, error: readFailureError ?? ({ kind: "trace-file-not-found", message: "No local trace files were found.", } satisfies TraceDiagnosticsErrorSummary), }); } return aggregateTraceDiagnostics({ traceFilePath: options.traceFilePath, files, scannedFilePaths: paths, readAt, slowSpanThresholdMs, ...(readFailureError ? { partialFailure: true, error: readFailureError } : {}), }); }, ); return TraceDiagnostics.of({ read }); }); export const layer = Layer.effect(TraceDiagnostics, make()); export function readTraceDiagnostics( options: TraceDiagnosticsOptions, ): Effect.Effect { return Effect.gen(function* () { const diagnostics = yield* TraceDiagnostics; return yield* diagnostics.read(options); }); }