Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 79 additions & 27 deletions apps/server/src/diagnostics/ProcessDiagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -42,7 +42,7 @@ describe("ProcessDiagnostics", () => {
].join("\n"),
);

expect(rows).toEqual([
assert.deepStrictEqual(rows, [
{
pid: 10,
ppid: 1,
Expand All @@ -67,6 +67,44 @@ describe("ProcessDiagnostics", () => {
}),
);

it.effect("parses Windows process JSON through Effect Schema decoding", () =>
Effect.sync(() => {
const rows = ProcessDiagnostics.parseWindowsProcessRows(
[
"[",
'{"ProcessId":10,"ParentProcessId":1,"Name":"node.exe","CommandLine":"node server.js","Status":"Live","WorkingSetSize":1024,"PercentProcessorTime":1.5},',
'{"ProcessId":11,"ParentProcessId":10,"Name":"agent.exe","CommandLine":null,"Status":null,"WorkingSetSize":null,"PercentProcessorTime":null},',
'"not-a-process-row"',
"]",
].join(""),
);

assert.deepStrictEqual(rows, [
{
pid: 10,
ppid: 1,
pgid: null,
status: "Live",
cpuPercent: 1.5,
rssBytes: 1024,
elapsed: "",
command: "node server.js",
},
{
pid: 11,
ppid: 10,
pgid: null,
status: "Live",
cpuPercent: 0,
rssBytes: 0,
elapsed: "",
command: "agent.exe",
},
]);
assert.deepStrictEqual(ProcessDiagnostics.parseWindowsProcessRows("not-json"), []);
}),
);

it.effect("aggregates only descendants of the server process", () =>
Effect.sync(() => {
const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
Expand Down Expand Up @@ -126,15 +164,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.deepStrictEqual(
diagnostics.processes.map((process) => process.pid),
[101, 102],
);
assert.deepStrictEqual(
diagnostics.processes.map((process) => process.depth),
[0, 1],
);
assert.equal(Option.getOrNull(diagnostics.processes[0]!.pgid), 100);
assert.deepStrictEqual(diagnostics.processes[0]?.childPids, [102]);
}),
);

Expand Down Expand Up @@ -177,7 +221,10 @@ describe("ProcessDiagnostics", () => {
],
});

expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]);
assert.deepStrictEqual(
diagnostics.processes.map((process) => process.pid),
[101, 102, 103],
);
}),
);

Expand Down Expand Up @@ -210,8 +257,11 @@ describe("ProcessDiagnostics", () => {
Effect.provide(layer),
);

expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
expect(commands).toEqual([
assert.deepStrictEqual(
diagnostics.processes.map((process) => process.pid),
[4242],
);
assert.deepStrictEqual(commands, [
{
command: "ps",
args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],
Expand Down Expand Up @@ -241,18 +291,20 @@ describe("ProcessDiagnostics", () => {
Effect.flip,
);

expect(error).toMatchObject({
_tag: "ProcessDiagnosticsQueryFailedError",
command: "ps",
argCount: 2,
cwd: process.cwd(),
exitCode: 17,
stdoutBytes: 22,
stderrBytes: 21,
stdoutTruncated: false,
stderrTruncated: false,
});
expect(error.message).toBe(
assert.equal(error._tag, "ProcessDiagnosticsQueryFailedError");
if (error._tag !== "ProcessDiagnosticsQueryFailedError") {
assert.fail(`Expected ProcessDiagnosticsQueryFailedError, got ${error._tag}`);
}
assert.equal(error.command, "ps");
assert.equal(error.argCount, 2);
assert.equal(error.cwd, process.cwd());
assert.equal(error.exitCode, 17);
assert.equal(error.stdoutBytes, 22);
assert.equal(error.stderrBytes, 21);
assert.equal(error.stdoutTruncated, false);
assert.equal(error.stderrTruncated, false);
assert.equal(
error.message,
`Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`,
);
}),
Expand Down Expand Up @@ -280,7 +332,7 @@ describe("ProcessDiagnostics", () => {
Effect.provide(layer),
);

expect(result).toEqual({
assert.deepStrictEqual(result, {
pid: 4242,
signal: "SIGINT",
signaled: false,
Expand Down
36 changes: 23 additions & 13 deletions apps/server/src/diagnostics/ProcessDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,18 @@ const ProcessDiagnosticsError = Schema.Union([
type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);

const WindowsProcessRecord = Schema.Struct({
ProcessId: Schema.optionalKey(Schema.NullOr(Schema.Number)),
ParentProcessId: Schema.optionalKey(Schema.NullOr(Schema.Number)),
CommandLine: Schema.optionalKey(Schema.NullOr(Schema.String)),
Name: Schema.optionalKey(Schema.NullOr(Schema.String)),
Status: Schema.optionalKey(Schema.NullOr(Schema.String)),
WorkingSetSize: Schema.optionalKey(Schema.NullOr(Schema.Number)),
PercentProcessorTime: Schema.optionalKey(Schema.NullOr(Schema.Number)),
});
const decodeWindowsProcessJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord);

function parsePositiveInt(value: string): number | null {
const parsed = Number.parseInt(value, 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
Expand Down Expand Up @@ -202,8 +214,9 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow>
}

function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
const recordOption = decodeWindowsProcessRecord(value);
if (Option.isNone(recordOption)) return null;
const record = recordOption.value;
const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
const commandLine =
Expand Down Expand Up @@ -234,18 +247,15 @@ function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
};
}

function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
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 {
return [];
}
const parsedOption = decodeWindowsProcessJson(output);
if (Option.isNone(parsedOption)) return [];
const records = Array.isArray(parsedOption.value) ? parsedOption.value : [parsedOption.value];
return records.flatMap((record) => {
const row = normalizeWindowsProcessRow(record);
return row ? [row] : [];
});
}

export function buildDescendantEntries(
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/diagnostics/TraceDiagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ describe("TraceDiagnostics", () => {
durationMs: 50,
}),
"not-json",
"42",
].join("\n"),
},
{
Expand Down Expand Up @@ -123,7 +124,7 @@ describe("TraceDiagnostics", () => {
}),
"1970-01-01T00:00:05.025Z",
);
assert.equal(diagnostics.parseErrorCount, 1);
assert.equal(diagnostics.parseErrorCount, 2);
assert.equal(diagnostics.failureCount, 2);
assert.equal(diagnostics.interruptionCount, 1);
assert.equal(diagnostics.slowSpanCount, 1);
Expand Down
43 changes: 27 additions & 16 deletions apps/server/src/diagnostics/TraceDiagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,24 @@ interface TraceEventLike {
readonly attributes?: unknown;
}

const TraceEventRecord = Schema.Struct({
name: Schema.optionalKey(Schema.Unknown),
timeUnixNano: Schema.optionalKey(Schema.Unknown),
attributes: Schema.optionalKey(Schema.Unknown),
});
const TraceRecord = Schema.Struct({
name: Schema.optionalKey(Schema.Unknown),
traceId: Schema.optionalKey(Schema.Unknown),
spanId: Schema.optionalKey(Schema.Unknown),
startTimeUnixNano: Schema.optionalKey(Schema.Unknown),
endTimeUnixNano: Schema.optionalKey(Schema.Unknown),
durationMs: Schema.optionalKey(Schema.Unknown),
exit: Schema.optionalKey(Schema.Unknown),
events: Schema.optionalKey(Schema.Unknown),
});
const decodeTraceRecordLine = Schema.decodeUnknownOption(Schema.fromJsonString(TraceRecord));
const decodeTraceEventRecord = Schema.decodeUnknownOption(TraceEventRecord);

export interface TraceDiagnosticsOptions {
readonly traceFilePath: string;
readonly maxFiles: number;
Expand Down Expand Up @@ -123,10 +141,6 @@ function readExitCause(exit: unknown): string {
return toStringValue(exit.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>>)
Expand Down Expand Up @@ -229,18 +243,13 @@ export function aggregateTraceDiagnostics(
for (const line of lines) {
if (line.trim().length === 0) continue;

let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
const parsedOption = decodeTraceRecordLine(line);
if (Option.isNone(parsedOption)) {
parseErrorCount += 1;
continue;
}

if (!isRecordObject(parsed)) {
parseErrorCount += 1;
continue;
}
const parsed = parsedOption.value;

const name = toStringValue(parsed.name);
const traceId = toStringValue(parsed.traceId);
Expand Down Expand Up @@ -305,8 +314,10 @@ export function aggregateTraceDiagnostics(

if (Array.isArray(parsed.events)) {
for (const rawEvent of parsed.events) {
if (!isTraceEvent(rawEvent)) continue;
const attributes = readEventAttributes(rawEvent);
const eventOption = decodeTraceEventRecord(rawEvent);
if (Option.isNone(eventOption)) continue;
const event = eventOption.value;
const attributes = readEventAttributes(event);
const level = toStringValue(attributes["effect.logLevel"]);
if (!level) continue;

Expand All @@ -321,8 +332,8 @@ export function aggregateTraceDiagnostics(
continue;
}

const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt;
const message = toStringValue(rawEvent.name)?.trim() ?? "Log event";
const seenAt = unixNanoToDateTime(event.timeUnixNano) ?? endedAt;
const message = toStringValue(event.name)?.trim() ?? "Log event";
latestWarningAndErrorLogs.push({
spanName: name,
level,
Expand Down
Loading