diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts index 7d16a11c829..29f4f2e8886 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.test.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "@effect/vitest"; +import { assert, describe, it } from "@effect/vitest"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -42,11 +42,11 @@ describe("ProcessDiagnostics", () => { ].join("\n"), ); - expect(rows).toEqual([ + assert.deepEqual(rows, [ { pid: 10, ppid: 1, - pgid: 10, + pgid: Option.some(10), status: "Ss", cpuPercent: 0, rssBytes: 1024 * 1024, @@ -56,7 +56,7 @@ describe("ProcessDiagnostics", () => { { pid: 11, ppid: 10, - pgid: 10, + pgid: Option.some(10), status: "S+", cpuPercent: 12.5, rssBytes: 20480 * 1024, @@ -67,6 +67,43 @@ describe("ProcessDiagnostics", () => { }), ); + it.effect("parses Windows process JSON through Schema and skips invalid rows", () => + Effect.sync(() => { + const rows = ProcessDiagnostics.parseWindowsProcessRows( + `[ + { + "ProcessId": 10, + "ParentProcessId": 1, + "CommandLine": "", + "Name": "node.exe", + "Status": "", + "WorkingSetSize": 1024.4, + "PercentProcessorTime": 12.5 + }, + { + "ProcessId": "not-a-number", + "ParentProcessId": 1, + "Name": "bad.exe" + } + ]`, + ); + + assert.deepEqual(rows, [ + { + pid: 10, + ppid: 1, + pgid: Option.none(), + status: "Live", + cpuPercent: 12.5, + rssBytes: 1024, + elapsed: "", + command: "node.exe", + }, + ]); + assert.deepEqual(ProcessDiagnostics.parseWindowsProcessRows("not-json"), []); + }), + ); + it.effect("aggregates only descendants of the server process", () => Effect.sync(() => { const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({ @@ -76,7 +113,7 @@ describe("ProcessDiagnostics", () => { { pid: 100, ppid: 1, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 0, rssBytes: 1_000, @@ -86,7 +123,7 @@ describe("ProcessDiagnostics", () => { { pid: 101, ppid: 100, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 1.5, rssBytes: 2_000, @@ -96,7 +133,7 @@ describe("ProcessDiagnostics", () => { { pid: 102, ppid: 101, - pgid: 100, + pgid: Option.some(100), status: "R", cpuPercent: 3.25, rssBytes: 4_000, @@ -106,7 +143,7 @@ describe("ProcessDiagnostics", () => { { pid: 200, ppid: 1, - pgid: 200, + pgid: Option.some(200), status: "S", cpuPercent: 99, rssBytes: 8_000, @@ -116,7 +153,7 @@ describe("ProcessDiagnostics", () => { { pid: 201, ppid: 100, - pgid: 100, + pgid: Option.some(100), status: "R", cpuPercent: 9, rssBytes: 9_000, @@ -126,15 +163,21 @@ describe("ProcessDiagnostics", () => { ], }); - expect(diagnostics.serverPid).toBe(100); - expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z"); - expect(diagnostics.processCount).toBe(2); - expect(diagnostics.totalRssBytes).toBe(6_000); - expect(diagnostics.totalCpuPercent).toBe(4.75); - expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]); - expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]); - expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100); - expect(diagnostics.processes[0]?.childPids).toEqual([102]); + assert.equal(diagnostics.serverPid, 100); + assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z"); + assert.equal(diagnostics.processCount, 2); + assert.equal(diagnostics.totalRssBytes, 6_000); + assert.equal(diagnostics.totalCpuPercent, 4.75); + assert.deepEqual( + diagnostics.processes.map((process) => process.pid), + [101, 102], + ); + assert.deepEqual( + diagnostics.processes.map((process) => process.depth), + [0, 1], + ); + assert.equal(Option.getOrNull(diagnostics.processes[0]!.pgid), 100); + assert.deepEqual(diagnostics.processes[0]?.childPids, [102]); }), ); @@ -147,7 +190,7 @@ describe("ProcessDiagnostics", () => { { pid: 101, ppid: 100, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 0, rssBytes: 100, @@ -157,7 +200,7 @@ describe("ProcessDiagnostics", () => { { pid: 103, ppid: 101, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 0, rssBytes: 100, @@ -167,7 +210,7 @@ describe("ProcessDiagnostics", () => { { pid: 102, ppid: 101, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 0, rssBytes: 100, @@ -177,7 +220,10 @@ describe("ProcessDiagnostics", () => { ], }); - expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]); + assert.deepEqual( + diagnostics.processes.map((process) => process.pid), + [101, 102, 103], + ); }), ); @@ -210,8 +256,11 @@ describe("ProcessDiagnostics", () => { Effect.provide(layer), ); - expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]); - expect(commands).toEqual([ + assert.deepEqual( + diagnostics.processes.map((process) => process.pid), + [4242], + ); + assert.deepEqual(commands, [ { command: "ps", args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="], @@ -241,7 +290,7 @@ describe("ProcessDiagnostics", () => { Effect.flip, ); - expect(error).toMatchObject({ + assert.deepInclude(error, { _tag: "ProcessDiagnosticsQueryFailedError", command: "ps", argCount: 2, @@ -252,7 +301,8 @@ describe("ProcessDiagnostics", () => { stdoutTruncated: false, stderrTruncated: false, }); - expect(error.message).toBe( + assert.equal( + error.message, `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`, ); }), @@ -280,7 +330,7 @@ describe("ProcessDiagnostics", () => { Effect.provide(layer), ); - expect(result).toEqual({ + assert.deepEqual(result, { pid: 4242, signal: "SIGINT", signaled: false, diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index b39d560a228..3d9d41f57f0 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -11,16 +11,19 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; + import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; export interface ProcessRow { readonly pid: number; readonly ppid: number; - readonly pgid: number | null; + readonly pgid: Option.Option; readonly status: string; readonly cpuPercent: number; readonly rssBytes: number; @@ -31,6 +34,20 @@ export interface ProcessRow { const PROCESS_QUERY_TIMEOUT_MS = 1_000; const POSIX_PROCESS_QUERY_COMMAND = "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="; const PROCESS_QUERY_MAX_OUTPUT_BYTES = 2 * 1024 * 1024; +const NullableString = Schema.Union([Schema.String, Schema.Null]); +const NullableNumber = Schema.Union([Schema.Number, Schema.Null]); +const WindowsProcessRecord = Schema.Struct({ + ProcessId: Schema.Number, + ParentProcessId: Schema.Number, + CommandLine: Schema.optional(NullableString), + Name: Schema.optional(NullableString), + Status: Schema.optional(NullableString), + WorkingSetSize: Schema.optional(NullableNumber), + PercentProcessorTime: Schema.optional(NullableNumber), +}); +type WindowsProcessRecord = typeof WindowsProcessRecord.Type; +const decodeWindowsProcessJson = decodeJsonResult(Schema.Unknown); +const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord); export class ProcessDiagnostics extends Context.Service< ProcessDiagnostics, @@ -136,6 +153,14 @@ function parseNumber(value: string): number | null { return Number.isFinite(parsed) ? parsed : null; } +function toNonEmptyStringOption(value: string | null | undefined): Option.Option { + return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none(); +} + +function toNonNegativeNumber(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0; +} + export function parsePosixProcessRows(output: string): ReadonlyArray { const rows: ProcessRow[] = []; const rowPattern = @@ -189,7 +214,7 @@ export function parsePosixProcessRows(output: string): ReadonlyArray rows.push({ pid, ppid, - pgid, + pgid: Option.some(pgid), status, cpuPercent, rssBytes: rssKiB * 1024, @@ -201,51 +226,46 @@ export function parsePosixProcessRows(output: string): ReadonlyArray return rows; } -function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { - if (typeof value !== "object" || value === null) return null; - const record = value as Record; - const pid = typeof record.ProcessId === "number" ? record.ProcessId : null; - const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null; - const commandLine = - typeof record.CommandLine === "string" && record.CommandLine.trim().length > 0 - ? record.CommandLine - : typeof record.Name === "string" - ? record.Name - : null; - const workingSet = - typeof record.WorkingSetSize === "number" && Number.isFinite(record.WorkingSetSize) - ? Math.max(0, Math.round(record.WorkingSetSize)) - : 0; - const cpuPercent = - typeof record.PercentProcessorTime === "number" && Number.isFinite(record.PercentProcessorTime) - ? Math.max(0, record.PercentProcessorTime) - : 0; - - if (!pid || pid <= 0 || ppid === null || ppid < 0 || !commandLine) return null; - return { - pid, - ppid, - pgid: null, - status: typeof record.Status === "string" && record.Status.length > 0 ? record.Status : "Live", - cpuPercent, - rssBytes: workingSet, +function normalizeWindowsProcessRow(record: WindowsProcessRecord): Option.Option { + const commandLine = Option.firstSomeOf([ + toNonEmptyStringOption(record.CommandLine), + toNonEmptyStringOption(record.Name), + ]); + if ( + !Number.isInteger(record.ProcessId) || + record.ProcessId <= 0 || + !Number.isInteger(record.ParentProcessId) || + record.ParentProcessId < 0 || + Option.isNone(commandLine) + ) { + return Option.none(); + } + + return Option.some({ + pid: record.ProcessId, + ppid: record.ParentProcessId, + pgid: Option.none(), + status: Option.getOrElse(toNonEmptyStringOption(record.Status), () => "Live"), + cpuPercent: toNonNegativeNumber(record.PercentProcessorTime), + rssBytes: Math.round(toNonNegativeNumber(record.WorkingSetSize)), elapsed: "", - command: commandLine, - }; + command: commandLine.value, + }); } -function parseWindowsProcessRows(output: string): ReadonlyArray { +export function parseWindowsProcessRows(output: string): ReadonlyArray { if (output.trim().length === 0) return []; - try { - const parsed = JSON.parse(output) as unknown; - const records = Array.isArray(parsed) ? parsed : [parsed]; - return records.flatMap((record) => { - const row = normalizeWindowsProcessRow(record); - return row ? [row] : []; - }); - } catch { + const parsed = decodeWindowsProcessJson(output); + if (Result.isFailure(parsed)) { return []; } + const records = Array.isArray(parsed.success) ? parsed.success : [parsed.success]; + return records.flatMap((rawRecord) => { + const decodedRecord = decodeWindowsProcessRecord(rawRecord); + if (Option.isNone(decodedRecord)) return []; + const row = normalizeWindowsProcessRow(decodedRecord.value); + return Option.isSome(row) ? [row.value] : []; + }); } export function buildDescendantEntries( @@ -276,7 +296,7 @@ export function buildDescendantEntries( entries.push({ pid: item.row.pid, ppid: item.row.ppid, - pgid: Option.fromNullishOr(item.row.pgid), + pgid: item.row.pgid, status: item.row.status, cpuPercent: item.row.cpuPercent, rssBytes: item.row.rssBytes, diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts index d9c4eb06ef1..35b4079b43c 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.test.ts @@ -17,7 +17,7 @@ describe("ProcessResourceMonitor", () => { { pid: 100, ppid: 1, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 2, rssBytes: 1_000, @@ -27,7 +27,7 @@ describe("ProcessResourceMonitor", () => { { pid: 101, ppid: 100, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 10, rssBytes: 2_000, @@ -37,7 +37,7 @@ describe("ProcessResourceMonitor", () => { { pid: 102, ppid: 101, - pgid: 100, + pgid: Option.some(100), status: "R", cpuPercent: 50, rssBytes: 3_000, @@ -47,7 +47,7 @@ describe("ProcessResourceMonitor", () => { { pid: 200, ppid: 1, - pgid: 200, + pgid: Option.some(200), status: "R", cpuPercent: 99, rssBytes: 9_000, @@ -77,7 +77,7 @@ describe("ProcessResourceMonitor", () => { { pid: 100, ppid: 1, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 10, rssBytes: 1_000, @@ -94,7 +94,7 @@ describe("ProcessResourceMonitor", () => { { pid: 100, ppid: 1, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 30, rssBytes: 2_000, @@ -137,7 +137,7 @@ describe("ProcessResourceMonitor", () => { { pid: 100, ppid: 1, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 1, rssBytes: 1_000, @@ -154,7 +154,7 @@ describe("ProcessResourceMonitor", () => { { pid: 100, ppid: 1, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 2, rssBytes: 2_000, @@ -192,7 +192,7 @@ describe("ProcessResourceMonitor", () => { { pid: 100, ppid: 1, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 1, rssBytes: 1_000, @@ -202,7 +202,7 @@ describe("ProcessResourceMonitor", () => { ...Array.from({ length: 35 }, (_, index) => ({ pid: 200 + index, ppid: index === 0 ? 100 : 199 + index, - pgid: 100, + pgid: Option.some(100), status: "S", cpuPercent: 35 - index, rssBytes: 2_000 + index, diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index d54e033380c..3059c2ddedd 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -17,22 +17,11 @@ 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; -} +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; -interface TraceEventLike { - readonly name?: unknown; - readonly timeUnixNano?: unknown; - readonly attributes?: unknown; -} +type TraceRecordLike = Readonly>; + +type TraceEventLike = Readonly>; export interface TraceDiagnosticsOptions { readonly traceFilePath: string; @@ -81,6 +70,8 @@ interface TraceDiagnosticsErrorSummary { const DEFAULT_SLOW_SPAN_THRESHOLD_MS = 1_000; const TOP_LIMIT = 10; const RECENT_LIMIT = 20; +const TraceRecord = Schema.Record(Schema.String, Schema.Unknown); +const decodeTraceRecord = decodeJsonResult(TraceRecord); function toRotatedTracePaths(traceFilePath: string, maxFiles: number): ReadonlyArray { const backupCount = Math.max(0, Math.floor(maxFiles)); const backups = Array.from( @@ -94,33 +85,33 @@ function isRecordObject(value: unknown): value is TraceRecordLike { return typeof value === "object" && value !== null; } -function toStringValue(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 ? value : null; +function toStringOption(value: unknown): Option.Option { + return typeof value === "string" && value.trim().length > 0 ? Option.some(value) : Option.none(); } -function toNumberValue(value: unknown): number | null { - return typeof value === "number" && Number.isFinite(value) ? value : null; +function toNumberOption(value: unknown): Option.Option { + return typeof value === "number" && Number.isFinite(value) ? Option.some(value) : Option.none(); } -function unixNanoToDateTime(value: unknown): DateTime.Utc | null { - const text = toStringValue(value); - if (!text) return null; +function unixNanoToDateTime(value: unknown): Option.Option { + const text = toStringOption(value); + if (Option.isNone(text)) return Option.none(); try { - const millis = Number(BigInt(text) / 1_000_000n); - return Option.getOrNull(DateTime.make(millis)); + const millis = Number(BigInt(text.value) / 1_000_000n); + return DateTime.make(millis); } catch { - return null; + return Option.none(); } } -function readExitTag(exit: unknown): string | null { - if (!isRecordObject(exit) || !("_tag" in exit)) return null; - return toStringValue(exit._tag); +function readExitTag(exit: unknown): Option.Option { + if (!isRecordObject(exit) || !("_tag" in exit)) return Option.none(); + return toStringOption(exit._tag); } function readExitCause(exit: unknown): string { if (!isRecordObject(exit) || !("cause" in exit)) return "Failure"; - return toStringValue(exit.cause)?.trim() ?? "Failure"; + return Option.getOrUndefined(toStringOption(exit.cause))?.trim() ?? "Failure"; } function isTraceEvent(value: unknown): value is TraceEventLike { @@ -211,8 +202,8 @@ export function aggregateTraceDiagnostics( let failureCount = 0; let interruptionCount = 0; let slowSpanCount = 0; - let firstSpanAt: DateTime.Utc | null = null; - let lastSpanAt: DateTime.Utc | null = null; + let firstSpanAt: Option.Option = Option.none(); + let lastSpanAt: Option.Option = Option.none(); const spansByName = new Map< string, @@ -229,40 +220,49 @@ export function aggregateTraceDiagnostics( for (const line of lines) { if (line.trim().length === 0) continue; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { + const parsedRecord = decodeTraceRecord(line); + if (Result.isFailure(parsedRecord)) { parseErrorCount += 1; continue; } + const parsed = parsedRecord.success; - 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 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 (!name || !traceId || !spanId || durationMs === null || !endedAt) { + 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; + const endedAtValue = endedAt.value; + recordCount += 1; - firstSpanAt = - startedAt && (firstSpanAt === null || DateTime.isLessThan(startedAt, firstSpanAt)) - ? startedAt - : firstSpanAt; - lastSpanAt = - lastSpanAt === null || DateTime.isGreaterThan(endedAt, lastSpanAt) ? endedAt : lastSpanAt; - - const exitTag = readExitTag(parsed.exit); + if ( + Option.isSome(startedAt) && + (Option.isNone(firstSpanAt) || DateTime.isLessThan(startedAt.value, firstSpanAt.value)) + ) { + firstSpanAt = startedAt; + } + if (Option.isNone(lastSpanAt) || DateTime.isGreaterThan(endedAtValue, lastSpanAt.value)) { + lastSpanAt = Option.some(endedAtValue); + } + + const exitTag = Option.getOrUndefined(readExitTag(parsed.exit)); const isFailure = exitTag === "Failure"; const isInterrupted = exitTag === "Interrupted"; if (isFailure) failureCount += 1; @@ -280,7 +280,7 @@ export function aggregateTraceDiagnostics( if (isFailure) spanSummary.failureCount += 1; spansByName.set(name, spanSummary); - const spanItem = { name, durationMs, endedAt, traceId, spanId }; + const spanItem = { name, durationMs, endedAt: endedAtValue, traceId, spanId }; if (durationMs >= slowSpanThresholdMs) { slowSpanCount += 1; } @@ -292,12 +292,13 @@ export function aggregateTraceDiagnostics( const failureKey = `${name}\0${cause}`; const existing = failuresByKey.get(failureKey); - const isLatestFailure = !existing || DateTime.isGreaterThan(endedAt, existing.lastSeenAt); + const isLatestFailure = + !existing || DateTime.isGreaterThan(endedAtValue, existing.lastSeenAt); failuresByKey.set(failureKey, { name, cause, count: (existing?.count ?? 0) + 1, - lastSeenAt: isLatestFailure ? endedAt : existing!.lastSeenAt, + lastSeenAt: isLatestFailure ? endedAtValue : existing!.lastSeenAt, traceId: isLatestFailure ? traceId : existing!.traceId, spanId: isLatestFailure ? spanId : existing!.spanId, }); @@ -307,8 +308,9 @@ export function aggregateTraceDiagnostics( for (const rawEvent of parsed.events) { if (!isTraceEvent(rawEvent)) continue; const attributes = readEventAttributes(rawEvent); - const level = toStringValue(attributes["effect.logLevel"]); - if (!level) continue; + 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(); @@ -321,8 +323,12 @@ export function aggregateTraceDiagnostics( continue; } - const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt; - const message = toStringValue(rawEvent.name)?.trim() ?? "Log event"; + const seenAt = Option.getOrElse( + unixNanoToDateTime(rawEvent.timeUnixNano), + () => endedAtValue, + ); + const message = + Option.getOrUndefined(toStringOption(rawEvent.name))?.trim() ?? "Log event"; latestWarningAndErrorLogs.push({ spanName: name, level, @@ -354,8 +360,8 @@ export function aggregateTraceDiagnostics( readAt, recordCount, parseErrorCount, - firstSpanAt: Option.fromNullishOr(firstSpanAt), - lastSpanAt: Option.fromNullishOr(lastSpanAt), + firstSpanAt, + lastSpanAt, failureCount, interruptionCount, slowSpanThresholdMs,