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 @@
1import { describe, expect, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
@@ -42,7 +42,7 @@ describe("ProcessDiagnostics", () => { · 37 unmodified lines
42 ].join("\n"),42 ].join("\n"),
43 );43 );
44 44
45 expect(rows).toEqual([45 assert.deepStrictEqual(rows, [
46 {46 {
47 pid: 10,47 pid: 10,
48 ppid: 1,48 ppid: 1,
@@ -67,6 +67,44 @@ describe("ProcessDiagnostics", () => { · 18 unmodified lines
67 }),67 }),
68 );68 );
69 69
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
70 it.effect("aggregates only descendants of the server process", () =>108 it.effect("aggregates only descendants of the server process", () =>
71 Effect.sync(() => {109 Effect.sync(() => {
72 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({110 const diagnostics = ProcessDiagnostics.aggregateProcessDiagnostics({
@@ -126,15 +164,21 @@ describe("ProcessDiagnostics", () => { · 53 unmodified lines
126 ],164 ],
127 });165 });
128 166
129 expect(diagnostics.serverPid).toBe(100);167 assert.equal(diagnostics.serverPid, 100);
130 expect(DateTime.formatIso(diagnostics.readAt)).toBe("2026-05-05T10:00:00.000Z");168 assert.equal(DateTime.formatIso(diagnostics.readAt), "2026-05-05T10:00:00.000Z");
131 expect(diagnostics.processCount).toBe(2);169 assert.equal(diagnostics.processCount, 2);
132 expect(diagnostics.totalRssBytes).toBe(6_000);170 assert.equal(diagnostics.totalRssBytes, 6_000);
133 expect(diagnostics.totalCpuPercent).toBe(4.75);171 assert.equal(diagnostics.totalCpuPercent, 4.75);
134 expect(diagnostics.processes.map((process) => process.pid)).toEqual([101, 102]);172 assert.deepStrictEqual(
135 expect(diagnostics.processes.map((process) => process.depth)).toEqual([0, 1]);173 diagnostics.processes.map((process) => process.pid),
136 expect(Option.getOrNull(diagnostics.processes[0]!.pgid)).toBe(100);174 [101, 102],
137 expect(diagnostics.processes[0]?.childPids).toEqual([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]);
138 }),182 }),
139 );183 );
140 184
@@ -177,7 +221,10 @@ describe("ProcessDiagnostics", () => { · 36 unmodified lines
177 ],221 ],
178 });222 });
179 223
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 );
181 }),228 }),
182 );229 );
183 230
@@ -210,8 +257,11 @@ describe("ProcessDiagnostics", () => { · 26 unmodified lines
210 Effect.provide(layer),257 Effect.provide(layer),
211 );258 );
212 259
213 expect(diagnostics.processes.map((process) => process.pid)).toEqual([4242]);260 assert.deepStrictEqual(
214 expect(commands).toEqual([261 diagnostics.processes.map((process) => process.pid),
262 [4242],
263 );
264 assert.deepStrictEqual(commands, [
215 {265 {
216 command: "ps",266 command: "ps",
217 args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],267 args: ["-axo", "pid=,ppid=,pgid=,stat=,pcpu=,rss=,etime=,command="],
@@ -241,18 +291,17 @@ describe("ProcessDiagnostics", () => { · 23 unmodified lines
241 Effect.flip,291 Effect.flip,
242 );292 );
243 293
244 expect(error).toMatchObject({294 assert.equal(error._tag, "ProcessDiagnosticsQueryFailedError");
245 _tag: "ProcessDiagnosticsQueryFailedError",295 assert.equal(error.command, "ps");
246 command: "ps",296 assert.equal(error.argCount, 2);
247 argCount: 2,297 assert.equal(error.cwd, process.cwd());
248 cwd: process.cwd(),298 assert.equal(error.exitCode, 17);
249 exitCode: 17,299 assert.equal(error.stdoutBytes, 22);
250 stdoutBytes: 22,300 assert.equal(error.stderrBytes, 21);
251 stderrBytes: 21,301 assert.equal(error.stdoutTruncated, false);
252 stdoutTruncated: false,302 assert.equal(error.stderrTruncated, false);
253 stderrTruncated: false,303 assert.equal(
254 });304 error.message,
255 expect(error.message).toBe(
256 `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`,305 `Process diagnostics query 'ps' failed with exit code 17 in '${process.cwd()}'.`,
257 );306 );
258 }),307 }),
@@ -280,7 +329,7 @@ describe("ProcessDiagnostics", () => { · 21 unmodified lines
280 Effect.provide(layer),329 Effect.provide(layer),
281 );330 );
282 331
283 expect(result).toEqual({332 assert.deepStrictEqual(result, {
284 pid: 4242,333 pid: 4242,
285 signal: "SIGINT",334 signal: "SIGINT",
286 signaled: false,335 signaled: false,
apps/server/src/diagnostics/ProcessDiagnostics.ts+23−13
@@ -121,6 +121,18 @@ const ProcessDiagnosticsError = Schema.Union([ · 120 unmodified lines
121type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;121type ProcessDiagnosticsError = typeof ProcessDiagnosticsError.Type;
122const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);122const isProcessDiagnosticsError = Schema.is(ProcessDiagnosticsError);
123 123
124const 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});
133const decodeWindowsProcessJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString);
134const decodeWindowsProcessRecord = Schema.decodeUnknownOption(WindowsProcessRecord);
135
124function parsePositiveInt(value: string): number | null {136function parsePositiveInt(value: string): number | null {
125 const parsed = Number.parseInt(value, 10);137 const parsed = Number.parseInt(value, 10);
126 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;138 return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
@@ -202,8 +214,9 @@ export function parsePosixProcessRows(output: string): ReadonlyArray<ProcessRow> · 75 unmodified lines
202}214}
203 215
204function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {216function normalizeWindowsProcessRow(value: unknown): ProcessRow | null {
205 if (typeof value !== "object" || value === null) return null;217 const recordOption = decodeWindowsProcessRecord(value);
206 const record = value as Record<string, unknown>;218 if (Option.isNone(recordOption)) return null;
219 const record = recordOption.value;
207 const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;220 const pid = typeof record.ProcessId === "number" ? record.ProcessId : null;
208 const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;221 const ppid = typeof record.ParentProcessId === "number" ? record.ParentProcessId : null;
209 const commandLine =222 const commandLine =
@@ -234,18 +247,15 @@ function normalizeWindowsProcessRow(value: unknown): ProcessRow | null { · 24 unmodified lines
234 };247 };
235}248}
236 249
237function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {250export function parseWindowsProcessRows(output: string): ReadonlyArray<ProcessRow> {
238 if (output.trim().length === 0) return [];251 if (output.trim().length === 0) return [];
239 try {252 const parsedOption = decodeWindowsProcessJson(output);
240 const parsed = JSON.parse(output) as unknown;253 if (Option.isNone(parsedOption)) return [];
241 const records = Array.isArray(parsed) ? parsed : [parsed];254 const records = Array.isArray(parsedOption.value) ? parsedOption.value : [parsedOption.value];
242 return records.flatMap((record) => {255 return records.flatMap((record) => {
243 const row = normalizeWindowsProcessRow(record);256 const row = normalizeWindowsProcessRow(record);
244 return row ? [row] : [];257 return row ? [row] : [];
245 });258 });
246 } catch {
247 return [];
248 }
249}259}
250 260
251export function buildDescendantEntries(261export function buildDescendantEntries(
apps/server/src/diagnostics/TraceDiagnostics.test.ts+2−1
@@ -59,6 +59,7 @@ describe("TraceDiagnostics", () => { · 58 unmodified lines
59 durationMs: 50,59 durationMs: 50,
60 }),60 }),
61 "not-json",61 "not-json",
62 "42",
62 ].join("\n"),63 ].join("\n"),
63 },64 },
64 {65 {
@@ -123,7 +124,7 @@ describe("TraceDiagnostics", () => { · 58 unmodified lines
123 }),124 }),
124 "1970-01-01T00:00:05.025Z",125 "1970-01-01T00:00:05.025Z",
125 );126 );
126 assert.equal(diagnostics.parseErrorCount, 1);127 assert.equal(diagnostics.parseErrorCount, 2);
127 assert.equal(diagnostics.failureCount, 2);128 assert.equal(diagnostics.failureCount, 2);
128 assert.equal(diagnostics.interruptionCount, 1);129 assert.equal(diagnostics.interruptionCount, 1);
129 assert.equal(diagnostics.slowSpanCount, 1);130 assert.equal(diagnostics.slowSpanCount, 1);
apps/server/src/diagnostics/TraceDiagnostics.ts+27−16
@@ -34,6 +34,24 @@ interface TraceEventLike { · 33 unmodified lines
34 readonly attributes?: unknown;34 readonly attributes?: unknown;
35}35}
36 36
37const TraceEventRecord = Schema.Struct({
38 name: Schema.optionalKey(Schema.Unknown),
39 timeUnixNano: Schema.optionalKey(Schema.Unknown),
40 attributes: Schema.optionalKey(Schema.Unknown),
41});
42const 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});
52const decodeTraceRecordLine = Schema.decodeUnknownOption(Schema.fromJsonString(TraceRecord));
53const decodeTraceEventRecord = Schema.decodeUnknownOption(TraceEventRecord);
54
37export interface TraceDiagnosticsOptions {55export interface TraceDiagnosticsOptions {
38 readonly traceFilePath: string;56 readonly traceFilePath: string;
39 readonly maxFiles: number;57 readonly maxFiles: number;
@@ -123,10 +141,6 @@ function readExitCause(exit: unknown): string { · 83 unmodified lines
123 return toStringValue(exit.cause)?.trim() ?? "Failure";141 return toStringValue(exit.cause)?.trim() ?? "Failure";
124}142}
125 143
126function isTraceEvent(value: unknown): value is TraceEventLike {
127 return typeof value === "object" && value !== null;
128}
129
130function readEventAttributes(event: TraceEventLike): Readonly<Record<string, unknown>> {144function readEventAttributes(event: TraceEventLike): Readonly<Record<string, unknown>> {
131 return typeof event.attributes === "object" && event.attributes !== null145 return typeof event.attributes === "object" && event.attributes !== null
132 ? (event.attributes as Readonly<Record<string, unknown>>)146 ? (event.attributes as Readonly<Record<string, unknown>>)
@@ -229,18 +243,13 @@ export function aggregateTraceDiagnostics( · 96 unmodified lines
229 for (const line of lines) {243 for (const line of lines) {
230 if (line.trim().length === 0) continue;244 if (line.trim().length === 0) continue;
231 245
232 let parsed: unknown;246 const parsedOption = decodeTraceRecordLine(line);
233 try {247 if (Option.isNone(parsedOption)) {
234 parsed = JSON.parse(line);
235 } catch {
236 parseErrorCount += 1;248 parseErrorCount += 1;
237 continue;249 continue;
238 }250 }
239 251
240 if (!isRecordObject(parsed)) {252 const parsed = parsedOption.value;
241 parseErrorCount += 1;
242 continue;
243 }
244 253
245 const name = toStringValue(parsed.name);254 const name = toStringValue(parsed.name);
246 const traceId = toStringValue(parsed.traceId);255 const traceId = toStringValue(parsed.traceId);
@@ -305,8 +314,10 @@ export function aggregateTraceDiagnostics( · 58 unmodified lines
305 314
306 if (Array.isArray(parsed.events)) {315 if (Array.isArray(parsed.events)) {
307 for (const rawEvent of parsed.events) {316 for (const rawEvent of parsed.events) {
308 if (!isTraceEvent(rawEvent)) continue;317 const eventOption = decodeTraceEventRecord(rawEvent);
309 const attributes = readEventAttributes(rawEvent);318 if (Option.isNone(eventOption)) continue;
319 const event = eventOption.value;
320 const attributes = readEventAttributes(event);
310 const level = toStringValue(attributes["effect.logLevel"]);321 const level = toStringValue(attributes["effect.logLevel"]);
311 if (!level) continue;322 if (!level) continue;
312 323
@@ -321,8 +332,8 @@ export function aggregateTraceDiagnostics( · 8 unmodified lines
321 continue;332 continue;
322 }333 }
323 334
324 const seenAt = unixNanoToDateTime(rawEvent.timeUnixNano) ?? endedAt;335 const seenAt = unixNanoToDateTime(event.timeUnixNano) ?? endedAt;
325 const message = toStringValue(rawEvent.name)?.trim() ?? "Log event";336 const message = toStringValue(event.name)?.trim() ?? "Log event";
326 latestWarningAndErrorLogs.push({337 latestWarningAndErrorLogs.push({
327 spanName: name,338 spanName: name,
328 level,339 level,