494 lines · 16.5 KB · TypeScript
RawDownload
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<ServerTraceDiagnosticsResult>;} export class TraceDiagnostics extends Context.Service<TraceDiagnostics, TraceDiagnosticsShape>()(  "t3/diagnostics/TraceDiagnostics",) {} interface TraceDiagnosticsInput {  readonly traceFilePath: string;  readonly files: ReadonlyArray<{ readonly path: string; readonly text: string }>;  readonly scannedFilePaths?: ReadonlyArray<string>;  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<string> {  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<string> {  return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none();} function toNumberOption(value: unknown): Option.Option<number> {  return typeof value === "number" && Number.isFinite(value) ? Option.some(value) : Option.none();} function unixNanoToDateTime(value: unknown): Option.Option<DateTime.Utc> {  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<TraceExit> {  return decodeTraceExitOption(exit);} function readExitTag(exit: unknown): Option.Option<string> {  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<DateTime.Utc>,  candidate: Option.Option<DateTime.Utc>,): Option.Option<DateTime.Utc> {  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<DateTime.Utc>,  candidate: DateTime.Utc,): Option.Option<DateTime.Utc> {  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<string>;  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<DateTime.Utc>();  let lastSpanAt = Option.none<DateTime.Utc>();   const spansByName = new Map<    string,    { count: number; failureCount: number; totalDurationMs: number; maxDurationMs: number }  >();  const failuresByKey = new Map<string, ServerTraceDiagnosticsFailureSummary>();  const latestFailures: ServerTraceDiagnosticsRecentFailure[] = [];  const slowestSpans: ServerTraceDiagnosticsSpanOccurrence[] = [];  const latestWarningAndErrorLogs: ServerTraceDiagnosticsLogEvent[] = [];  const logLevelCounts: Record<string, number> = {};   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<Record<string, unknown>>,          );          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<TraceFileReadResult> {  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<ServerTraceDiagnosticsResult, never, TraceDiagnostics> {  return Effect.gen(function* () {    const diagnostics = yield* TraceDiagnostics;    return yield* diagnostics.read(options);  });}