535 lines · 16.9 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 Result from "effect/Result";import * as Schema from "effect/Schema"; interface TraceRecordLike {  readonly name?: unknown;  readonly traceId?: unknown;  readonly spanId?: unknown;  readonly startTimeUnixNano?: unknown;  readonly endTimeUnixNano?: unknown;  readonly durationMs?: unknown;  readonly exit?: unknown;  readonly events?: unknown;} interface TraceEventLike {  readonly name?: unknown;  readonly timeUnixNano?: unknown;  readonly attributes?: unknown;} export interface TraceDiagnosticsOptions {  readonly traceFilePath: string;  readonly maxFiles: number;  readonly slowSpanThresholdMs?: number;  readonly readAt?: DateTime.Utc;} export class TraceFileReadError extends Schema.TaggedErrorClass<TraceFileReadError>()(  "TraceFileReadError",  {    traceFilePath: Schema.String,    causeTag: Schema.String,    cause: Schema.Defect(),  },) {  override get message(): string {    return `Failed to read local trace file '${this.traceFilePath}'.`;  }} export class TraceDiagnostics extends Context.Service<  TraceDiagnostics,  {    readonly read: (      options: TraceDiagnosticsOptions,    ) => Effect.Effect<ServerTraceDiagnosticsResult>;  }>()("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;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 isRecordObject(value: unknown): value is TraceRecordLike {  return typeof value === "object" && value !== null;} function toStringValue(value: unknown): Option.Option<string> {  return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none();} function toNumberValue(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 = toStringValue(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 readExitTag(exit: unknown): Option.Option<string> {  if (!isRecordObject(exit) || !("_tag" in exit)) return Option.none();  return toStringValue(exit._tag);} function readExitCause(exit: unknown): string {  if (!isRecordObject(exit) || !("cause" in exit)) return "Failure";  return Option.getOrElse(    Option.map(toStringValue(exit.cause), (cause) => cause.trim()),    () => "Failure",  );} function isTraceEvent(value: unknown): value is TraceEventLike {  return typeof value === "object" && value !== null;} function readEventAttributes(event: TraceEventLike): Readonly<Record<string, unknown>> {  return typeof event.attributes === "object" && event.attributes !== null    ? (event.attributes as Readonly<Record<string, unknown>>)    : {};} 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 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;  }} function earliestDateTime(  current: Option.Option<DateTime.Utc>,  candidate: DateTime.Utc,): Option.Option<DateTime.Utc> {  return Option.match(current, {    onNone: () => Option.some(candidate),    onSome: (value) => (DateTime.isLessThan(candidate, value) ? Option.some(candidate) : current),  });} function latestDateTime(  current: Option.Option<DateTime.Utc>,  candidate: DateTime.Utc,): Option.Option<DateTime.Utc> {  return Option.match(current, {    onNone: () => Option.some(candidate),    onSome: (value) =>      DateTime.isGreaterThan(candidate, value) ? Option.some(candidate) : current,  });} 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.Option<DateTime.Utc> = Option.none();  let lastSpanAt: Option.Option<DateTime.Utc> = Option.none();   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;       let parsed: unknown;      try {        parsed = JSON.parse(line);      } catch {        parseErrorCount += 1;        continue;      }       if (!isRecordObject(parsed)) {        parseErrorCount += 1;        continue;      }       const name = toStringValue(parsed.name);      const traceId = toStringValue(parsed.traceId);      const spanId = toStringValue(parsed.spanId);      const durationMs = toNumberValue(parsed.durationMs);      const endedAt = unixNanoToDateTime(parsed.endTimeUnixNano);      const startedAt = unixNanoToDateTime(parsed.startTimeUnixNano);      const spanFields = Option.all({ name, traceId, spanId, durationMs, endedAt });       if (Option.isNone(spanFields)) {        parseErrorCount += 1;        continue;      }      const {        name: spanName,        traceId: spanTraceId,        spanId: spanSpanId,        durationMs: spanDurationMs,        endedAt: spanEndedAt,      } = spanFields.value;       recordCount += 1;      if (Option.isSome(startedAt)) {        firstSpanAt = earliestDateTime(firstSpanAt, startedAt.value);      }      lastSpanAt = latestDateTime(lastSpanAt, spanEndedAt);       const exitTag = readExitTag(parsed.exit);      const isFailure = Option.isSome(exitTag) && exitTag.value === "Failure";      const isInterrupted = Option.isSome(exitTag) && exitTag.value === "Interrupted";      if (isFailure) failureCount += 1;      if (isInterrupted) interruptionCount += 1;       const spanSummary = spansByName.get(spanName) ?? {        count: 0,        failureCount: 0,        totalDurationMs: 0,        maxDurationMs: 0,      };      spanSummary.count += 1;      spanSummary.totalDurationMs += spanDurationMs;      spanSummary.maxDurationMs = Math.max(spanSummary.maxDurationMs, spanDurationMs);      if (isFailure) spanSummary.failureCount += 1;      spansByName.set(spanName, spanSummary);       const spanItem = {        name: spanName,        durationMs: spanDurationMs,        endedAt: spanEndedAt,        traceId: spanTraceId,        spanId: spanSpanId,      };      if (spanDurationMs >= slowSpanThresholdMs) {        slowSpanCount += 1;      }      insertBoundedSlowestSpan(slowestSpans, spanItem);       if (isFailure) {        const cause = readExitCause(parsed.exit);        latestFailures.push({ ...spanItem, cause });         const failureKey = `${spanName}\0${cause}`;        const existing = failuresByKey.get(failureKey);        const isLatestFailure =          !existing || DateTime.isGreaterThan(spanEndedAt, existing.lastSeenAt);        failuresByKey.set(failureKey, {          name: spanName,          cause,          count: (existing?.count ?? 0) + 1,          lastSeenAt: isLatestFailure ? spanEndedAt : existing!.lastSeenAt,          traceId: isLatestFailure ? spanTraceId : existing!.traceId,          spanId: isLatestFailure ? spanSpanId : existing!.spanId,        });      }       if (Array.isArray(parsed.events)) {        for (const rawEvent of parsed.events) {          if (!isTraceEvent(rawEvent)) continue;          const attributes = readEventAttributes(rawEvent);          const level = toStringValue(attributes["effect.logLevel"]);          if (Option.isNone(level)) continue;           logLevelCounts[level.value] = (logLevelCounts[level.value] ?? 0) + 1;          const normalizedLevel = level.value.toLowerCase();          if (            normalizedLevel !== "warning" &&            normalizedLevel !== "warn" &&            normalizedLevel !== "error" &&            normalizedLevel !== "fatal"          ) {            continue;          }           const seenAt = Option.getOrElse(            unixNanoToDateTime(rawEvent.timeUnixNano),            () => spanEndedAt,          );          const message = Option.getOrElse(            Option.map(toStringValue(rawEvent.name), (eventName) => eventName.trim()),            () => "Log event",          );          latestWarningAndErrorLogs.push({            spanName,            level: level.value,            message,            seenAt,            traceId: spanTraceId,            spanId: spanSpanId,          });        }      }    }  }   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 }; function readTraceFile(  fileSystem: FileSystem.FileSystem,  path: string,): Effect.Effect<TraceFileReadResult, TraceFileReadError> {  return fileSystem.readFileString(path).pipe(    Effect.map((text): TraceFileReadResult => ({ _tag: "Loaded", path, text })),    Effect.catchTags({      PlatformError: (cause) =>        isNotFoundError(cause)          ? Effect.succeed<TraceFileReadResult>({ _tag: "Missing", path })          : Effect.fail(              new TraceFileReadError({                traceFilePath: path,                causeTag: cause.reason._tag,                cause,              }),            ),    }),  );} export const make = Effect.gen(function* () {  const fileSystem = yield* FileSystem.FileSystem;   const read: TraceDiagnostics["Service"]["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).pipe(            Effect.tapError((cause) =>              Effect.logWarning("Failed to read local trace file.").pipe(                Effect.annotateLogs({                  traceFilePath: cause.traceFilePath,                  errorTag: cause._tag,                  causeTag: cause.causeTag,                }),              ),            ),            Effect.result,          ),        ),        {          concurrency: 1,        },      );      const files = results.flatMap((result) =>        Result.isSuccess(result) && result.success._tag === "Loaded"          ? [{ path: result.success.path, text: result.success.text }]          : [],      );      const readFailure = results.find(Result.isFailure);      const readFailureError = readFailure        ? ({            kind: "trace-file-read-failed",            message: readFailure.failure.message,          } satisfies TraceDiagnosticsErrorSummary)        : undefined;       if (files.length === 0) {        return makeEmptyDiagnostics({          traceFilePath: options.traceFilePath,          scannedFilePaths: paths,

Lines 1–500 of 535.