History

Use schema decoding for diagnostics JSON

Cursor Agent committed 4 months ago

Commit ab528361 parent 32e78444 files · +128 −57Browse files

Unified · Split

4 files
  1. apps/server/src/diagnostics/ProcessDiagnostics.test.ts +76 −27
  2. apps/server/src/diagnostics/ProcessDiagnostics.ts +23 −13
  3. apps/server/src/diagnostics/TraceDiagnostics.test.ts +2 −1
  4. apps/server/src/diagnostics/TraceDiagnostics.ts +27 −16
apps/server/src/diagnostics/ProcessDiagnostics.test.ts+76−27
@@ -1,4 +1,4 @@
1−import { describe, expect, it } from "@effect/vitest";
1+import { assert, describe, it } from "@effect/vitest";
22 import * as DateTime from "effect/DateTime";
33 import * as Effect from "effect/Effect";
44 import * as Layer from "effect/Layer";
@@ -42,7 +42,7 @@ describe("ProcessDiagnostics", () => { · 37 unmodified lines
4242 ].join("\n"),
4343 );
4444
45− expect(rows).toEqual([
45+ assert.deepStrictEqual(rows, [
4646 {
4747 pid: 10,
4848 ppid: 1,
@@ -67,6 +67,44 @@ describe("ProcessDiagnostics", () => { · 18 unmodified lines
6767 }),
6868 );
6969
70+ it.effect("parses Windows process JSON through Effect Schema decoding", () =>
71+ Effect.sync(() => {
72+ const rows = ProcessDiagnostics.parseWindowsProcessRows(
73+ [
74+ "[",
75+ '{"ProcessId":10,"ParentProcessId":1,"Name":"node.exe","CommandLine":"node server.js","Status":"Live","WorkingSetSize":1024,"PercentProcessorTime":1.5},',
76+ '{"ProcessId":11,"ParentProcessId":10,"Name":"agent.exe","CommandLine":null,"Status":null,"WorkingSetSize":null,"PercentProcessorTime":null},',
77+ '"not-a-process-row"',
78+ "]",
79+ ].join(""),
80+ );
81+
82+ assert.deepStrictEqual(rows, [
83+ {
84+ pid: 10,
85+ ppid: 1,
86+ pgid: null,
87+ status: "Live",
88+ cpuPercent: 1.5,
89+ rssBytes: 1024,
90+ elapsed: "",
91+ command: "node server.js",
92+ },
93+ {
94+ pid: 11,
95+ ppid: 10,
96+ pgid: null,
97+ status: "Live",
98+ cpuPercent: 0,
99+ rssBytes: 0,
100+ elapsed: "",
101+ command: "agent.exe",
102+ },
103+ ]);
104+ assert.deepStrictEqual(ProcessDiagnostics.parseWindowsProcessRows("not-json"), []);
105+ }),
106+ );
107+
70108 it.effect("aggregates only descendants of the server process", () =>
71109 Effect.sync(() => {
72110 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
@@ -126,15 +164,21 @@ describe("ProcessDiagnostics", () => { · 53 unmodified lines
126164 ],
127165 });
128166
129− expect(diagnostics.serverPid).toBe(100);
130− expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z");
131− expect(diagnostics.processCount).toBe(2);
132− expect(diagnostics.totalRssBytes).toBe(6_000);
133− expect(diagnostics.totalCpuPercent).toBe(4.75);
134− expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]);
135− expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]);
136− expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100);
137− expect(diagnostics.processes[0]?.childPids).toEqual([102]);
167+ assert.equal(diagnostics.serverPid, 100);
168+ assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z");
169+ assert.equal(diagnostics.processCount, 2);
170+ assert.equal(diagnostics.totalRssBytes, 6_000);
171+ assert.equal(diagnostics.totalCpuPercent, 4.75);
172+ assert.deepStrictEqual(
173+ diagnostics.processes.map((process) => process.pid),
174+ [101, 102],
175+ );
176+ assert.deepStrictEqual(
177+ diagnostics.processes.map((process) => process.depth),
178+ [0, 1],
179+ );
180+ assert.equal(Option.getOrNull(diagnostics.processes[0]!.pgid), 100);
181+ assert.deepStrictEqual(diagnostics.processes[0]?.childPids, [102]);
138182 }),
139183 );
140184
@@ -177,7 +221,10 @@ describe("ProcessDiagnostics", () => { · 36 unmodified lines
177221 ],
178222 });
179223
180− expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102, 103]);
224+ assert.deepStrictEqual(
225+ diagnostics.processes.map((process) => process.pid),
226+ [101, 102, 103],
227+ );
181228 }),
182229 );
183230
@@ -210,8 +257,11 @@ describe("ProcessDiagnostics", () => { · 26 unmodified lines
210257 Effect.provide(layer),
211258 );
212259
213− expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);
214− expect(commands).toEqual([
260+ assert.deepStrictEqual(
261+ diagnostics.processes.map((process) => process.pid),
262+ [4242],
263+ );
264+ assert.deepStrictEqual(commands, [
215265 {
216266 command: "ps",
217267 args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],
@@ -241,18 +291,17 @@ describe("ProcessDiagnostics", () => { · 23 unmodified lines
241291 Effect.flip,
242292 );
243293
244− expect(error).toMatchObject({
245− _tag: "ProcessDiagnosticsQueryFailedError",
246− command: "ps",
247− argCount: 2,
248− cwd: process.cwd(),
249− exitCode: 17,
250− stdoutBytes: 22,
251− stderrBytes: 21,
252− stdoutTruncated: false,
253− stderrTruncated: false,
254− });
255− expect(error.message).toBe(
294+ assert.equal(error._tag, "ProcessDiagnosticsQueryFailedError");
295+ assert.equal(error.command, "ps");
296+ assert.equal(error.argCount, 2);
297+ assert.equal(error.cwd, process.cwd());
298+ assert.equal(error.exitCode, 17);
299+ assert.equal(error.stdoutBytes, 22);
300+ assert.equal(error.stderrBytes, 21);
301+ assert.equal(error.stdoutTruncated, false);
302+ assert.equal(error.stderrTruncated, false);
303+ assert.equal(
304+ error.message,
256305 `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`,
257306 );
258307 }),
@@ -280,7 +329,7 @@ describe("ProcessDiagnostics", () => { · 21 unmodified lines
280329 Effect.provide(layer),
281330 );
282331
283− expect(result).toEqual({
332+ assert.deepStrictEqual(result, {
284333 pid: 4242,
285334 signal: "SIGINT",
286335 signaled: false,
apps/server/src/diagnostics/ProcessDiagnostics.ts+23−13
@@ -121,6 +121,18 @@ const ProcessDiagnosticsError = Schema.Union([ · 120 unmodified lines
121121 type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
122122 const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
123123
124+const WindowsProcessRecord = Schema.Struct({
125+ ProcessId: Schema.optionalKey(Schema.NullOr(Schema.Number)),
126+ ParentProcessId: Schema.optionalKey(Schema.NullOr(Schema.Number)),
127+ CommandLine: Schema.optionalKey(Schema.NullOr(Schema.String)),
128+ Name: Schema.optionalKey(Schema.NullOr(Schema.String)),
129+ Status: Schema.optionalKey(Schema.NullOr(Schema.String)),
130+ WorkingSetSize: Schema.optionalKey(Schema.NullOr(Schema.Number)),
131+ PercentProcessorTime: Schema.optionalKey(Schema.NullOr(Schema.Number)),
132+});
133+const decodeWindowsProcessJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
134+const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord);
135+
124136 function parsePositiveInt(value: string): number | null {
125137 const parsed = Number.parseInt(value, 10);
126138 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
@@ -202,8 +214,9 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> · 75 unmodified lines
202214 }
203215
204216 function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
205− if (typeof value !== "object" || value === null) return null;
206− const record = value as Record<string, unknown>;
217+ const recordOption = decodeWindowsProcessRecord(value);
218+ if (Option.isNone(recordOption)) return null;
219+ const record = recordOption.value;
207220 const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
208221 const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
209222 const commandLine =
@@ -234,18 +247,15 @@ function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { · 24 unmodified lines
234247 };
235248 }
236249
237−function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
250+export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
238251 if (output.trim().length === 0) return [];
239− try {
240− const parsed = JSON.parse(output) as unknown;
241− const records = Array.isArray(parsed) ? parsed : [parsed];
242− return records.flatMap((record) => {
243− const row = normalizeWindowsProcessRow(record);
244− return row ? [row] : [];
245− });
246− } catch {
247− return [];
248− }
252+ const parsedOption = decodeWindowsProcessJson(output);
253+ if (Option.isNone(parsedOption)) return [];
254+ const records = Array.isArray(parsedOption.value) ? parsedOption.value : [parsedOption.value];
255+ return records.flatMap((record) => {
256+ const row = normalizeWindowsProcessRow(record);
257+ return row ? [row] : [];
258+ });
249259 }
250260
251261 export function buildDescendantEntries(
apps/server/src/diagnostics/TraceDiagnostics.test.ts+2−1
@@ -59,6 +59,7 @@ describe("TraceDiagnostics", () => { · 58 unmodified lines
5959 durationMs: 50,
6060 }),
6161 "not-json",
62+ "42",
6263 ].join("\n"),
6364 },
6465 {
@@ -123,7 +124,7 @@ describe("TraceDiagnostics", () => { · 58 unmodified lines
123124 }),
124125 "1970-01-01T00:00:05.025Z",
125126 );
126− assert.equal(diagnostics.parseErrorCount, 1);
127+ assert.equal(diagnostics.parseErrorCount, 2);
127128 assert.equal(diagnostics.failureCount, 2);
128129 assert.equal(diagnostics.interruptionCount, 1);
129130 assert.equal(diagnostics.slowSpanCount, 1);
apps/server/src/diagnostics/TraceDiagnostics.ts+27−16
@@ -34,6 +34,24 @@ interface TraceEventLike { · 33 unmodified lines
3434 readonly attributes?: unknown;
3535 }
3636
37+const TraceEventRecord = Schema.Struct({
38+ name: Schema.optionalKey(Schema.Unknown),
39+ timeUnixNano: Schema.optionalKey(Schema.Unknown),
40+ attributes: Schema.optionalKey(Schema.Unknown),
41+});
42+const TraceRecord = Schema.Struct({
43+ name: Schema.optionalKey(Schema.Unknown),
44+ traceId: Schema.optionalKey(Schema.Unknown),
45+ spanId: Schema.optionalKey(Schema.Unknown),
46+ startTimeUnixNano: Schema.optionalKey(Schema.Unknown),
47+ endTimeUnixNano: Schema.optionalKey(Schema.Unknown),
48+ durationMs: Schema.optionalKey(Schema.Unknown),
49+ exit: Schema.optionalKey(Schema.Unknown),
50+ events: Schema.optionalKey(Schema.Unknown),
51+});
52+const decodeTraceRecordLine = Schema.decodeUnknownOption(Schema.fromJsonString(TraceRecord));
53+const decodeTraceEventRecord = Schema.decodeUnknownOption(TraceEventRecord);
54+
3755 export interface TraceDiagnosticsOptions {
3856 readonly traceFilePath: string;
3957 readonly maxFiles: number;
@@ -123,10 +141,6 @@ function readExitCause(exit: unknown): string { · 83 unmodified lines
123141 return toStringValue(exit.cause)?.trim() ?? "Failure";
124142 }
125143
126−function isTraceEvent(value: unknown): value is TraceEventLike {
127− return typeof value === "object" && value !== null;
128−}
129−
130144 function readEventAttributes(event: TraceEventLike): Readonly<Record<string, unknown>> {
131145 return typeof event.attributes === "object" && event.attributes !== null
132146 ? (event.attributes as Readonly<Record<string, unknown>>)
@@ -229,18 +243,13 @@ export function aggregateTraceDiagnostics( · 96 unmodified lines
229243 for (const line of lines) {
230244 if (line.trim().length === 0) continue;
231245
232− let parsed: unknown;
233− try {
234− parsed = JSON.parse(line);
235− } catch {
246+ const parsedOption = decodeTraceRecordLine(line);
247+ if (Option.isNone(parsedOption)) {
236248 parseErrorCount += 1;
237249 continue;
238250 }
239251
240− if (!isRecordObject(parsed)) {
241− parseErrorCount += 1;
242− continue;
243− }
252+ const parsed = parsedOption.value;
244253
245254 const name = toStringValue(parsed.name);
246255 const traceId = toStringValue(parsed.traceId);
@@ -305,8 +314,10 @@ export function aggregateTraceDiagnostics( · 58 unmodified lines
305314
306315 if (Array.isArray(parsed.events)) {
307316 for (const rawEvent of parsed.events) {
308− if (!isTraceEvent(rawEvent)) continue;
309− const attributes = readEventAttributes(rawEvent);
317+ const eventOption = decodeTraceEventRecord(rawEvent);
318+ if (Option.isNone(eventOption)) continue;
319+ const event = eventOption.value;
320+ const attributes = readEventAttributes(event);
310321 const level = toStringValue(attributes["effect.logLevel"]);
311322 if (!level) continue;
312323
@@ -321,8 +332,8 @@ export function aggregateTraceDiagnostics( · 8 unmodified lines
321332 continue;
322333 }
323334
324− const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt;
325− const message = toStringValue(rawEvent.name)?.trim() ?? "Log event";
335+ const seenAt = unixNanoToDateTime(event.timeUnixNano) ?? endedAt;
336+ const message = toStringValue(event.name)?.trim() ?? "Log event";
326337 latestWarningAndErrorLogs.push({
327338 spanName: name,
328339 level,