History

feat: add native resource telemetry

Replace scheduled process shell probes with a persistent native collector, Electron power/process telemetry, Effect services, diagnostics UI, attribution, and release packaging.

Julius Marminge committed 4 months ago

Commit dbd8a8e1 parent 79ad07162 files · +7741 −1957Browse files

Showing the changes to apps/server/src/diagnostics/ProcessResourceMonitor.test.ts (1 of 62 files). Show all

Unified · Split

apps/server/src/diagnostics/ProcessResourceMonitor.test.ts+112−211
@@ -1,231 +1,132 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import type { ResourceTelemetryHistory } from "@t3tools/contracts";
23 import * as DateTime from "effect/DateTime";
34 import * as Effect from "effect/Effect";
5+import * as Layer from "effect/Layer";
46 import * as Option from "effect/Option";
7+import * as Stream from "effect/Stream";
58
6−import {
7− aggregateProcessResourceHistory,
8− collectMonitoredSamples,
9−} from "./ProcessResourceMonitor.ts";
9+import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
10+import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";
1011
1112 describe("ProcessResourceMonitor", () => {
12− it.effect("samples the server root process and descendants", () =>
13− Effect.sync(() => {
14− const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
15− const samples = collectMonitoredSamples({
16− serverPid: 100,
17− sampledAt,
18− sampledAtMs: DateTime.toEpochMillis(sampledAt),
19− rows: [
20− {
21− pid: 100,
22− ppid: 1,
23− pgid: 100,
24− status: "S",
25− cpuPercent: 2,
26− rssBytes: 1_000,
27− elapsed: "01:00",
28− command: "t3 server",
29− },
30− {
31− pid: 101,
32− ppid: 100,
33− pgid: 100,
34− status: "S",
35− cpuPercent: 10,
36− rssBytes: 2_000,
37− elapsed: "00:20",
38− command: "codex app-server",
39− },
40− {
41− pid: 102,
42− ppid: 101,
43− pgid: 100,
44− status: "R",
45− cpuPercent: 50,
46− rssBytes: 3_000,
47− elapsed: "00:05",
48− command: "rg needle",
49− },
13+ it.effect("projects resource telemetry history into the legacy diagnostics contract", () =>
14+ Effect.gen(function* () {
15+ const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
16+ const history: ResourceTelemetryHistory = {
17+ readAt,
18+ windowMs: 60_000,
19+ bucketMs: 10_000,
20+ sampleIntervalMs: 1_000,
21+ retainedSampleCount: 2,
22+ buckets: [
5023 {
51− pid: 200,
52− ppid: 1,
53− pgid: 200,
54− status: "R",
55− cpuPercent: 99,
56− rssBytes: 9_000,
57− elapsed: "00:05",
58− command: "unrelated",
24+ startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"),
25+ endedAt: readAt,
26+ avgCpuPercent: 15,
27+ maxCpuPercent: 25,
28+ maxRssBytes: 4_096,
29+ ioReadBytes: 1_024,
30+ ioWriteBytes: 2_048,
31+ maxProcessCount: 2,
5932 },
6033 ],
61− });
62−
63− expect(samples.map((sample) => sample.pid)).toEqual([100, 101, 102]);
64− expect(samples.map((sample) => sample.depth)).toEqual([0, 1, 2]);
65− expect(samples[0]?.isServerRoot).toBe(true);
66− expect(samples[1]?.isServerRoot).toBe(false);
67− }),
68− );
69−
70− it.effect("rolls samples up by process and CPU time", () =>
71− Effect.sync(() => {
72− const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
73− const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.000Z");
74− const samples = [
75− ...collectMonitoredSamples({
76− serverPid: 100,
77− sampledAt: firstAt,
78− sampledAtMs: DateTime.toEpochMillis(firstAt),
79− rows: [
80− {
81− pid: 100,
82− ppid: 1,
83− pgid: 100,
84− status: "S",
85− cpuPercent: 10,
86− rssBytes: 1_000,
87− elapsed: "01:00",
88− command: "t3 server",
89− },
90− ],
91− }),
92− ...collectMonitoredSamples({
93− serverPid: 100,
94− sampledAt: secondAt,
95− sampledAtMs: DateTime.toEpochMillis(secondAt),
96− rows: [
97− {
98− pid: 100,
99− ppid: 1,
100− pgid: 100,
101− status: "S",
102− cpuPercent: 30,
103− rssBytes: 2_000,
104− elapsed: "01:05",
105− command: "t3 server",
106− },
107− ],
108− }),
109− ];
110−
111− const result = aggregateProcessResourceHistory({
112− samples,
113− readAt: secondAt,
114− readAtMs: DateTime.toEpochMillis(secondAt),
115− windowMs: 60_000,
116− bucketMs: 10_000,
117− lastError: null,
118− });
119−
120− expect(Option.isNone(result.error)).toBe(true);
121− expect(result.topProcesses).toHaveLength(1);
122− expect(result.topProcesses[0]?.avgCpuPercent).toBe(20);
123− expect(result.topProcesses[0]?.maxCpuPercent).toBe(30);
124− expect(result.topProcesses[0]?.cpuSecondsApprox).toBe(2);
125− expect(result.totalCpuSecondsApprox).toBe(2);
126− expect(result.buckets.some((bucket) => bucket.maxCpuPercent === 30)).toBe(true);
127− }),
128− );
129−
130− it.effect("keeps a process grouped when elapsed time drifts between samples", () =>
131− Effect.sync(() => {
132− const firstAt = DateTime.makeUnsafe("2026-05-05T10:00:00.400Z");
133− const secondAt = DateTime.makeUnsafe("2026-05-05T10:00:05.900Z");
134− const samples = [
135− ...collectMonitoredSamples({
136− serverPid: 100,
137− sampledAt: firstAt,
138− sampledAtMs: DateTime.toEpochMillis(firstAt),
139− rows: [
140− {
141− pid: 100,
142− ppid: 1,
143− pgid: 100,
144− status: "S",
145− cpuPercent: 1,
146− rssBytes: 1_000,
147− elapsed: "01:00",
148− command: "t3 server",
149− },
150− ],
151− }),
152− ...collectMonitoredSamples({
153− serverPid: 100,
154− sampledAt: secondAt,
155− sampledAtMs: DateTime.toEpochMillis(secondAt),
156− rows: [
157− {
158− pid: 100,
159− ppid: 1,
160− pgid: 100,
161− status: "S",
162− cpuPercent: 2,
163− rssBytes: 2_000,
164− elapsed: "01:06",
165− command: "t3 server",
166− },
167− ],
168− }),
169− ];
170−
171− const result = aggregateProcessResourceHistory({
172− samples,
173− readAt: secondAt,
174− readAtMs: DateTime.toEpochMillis(secondAt),
175− windowMs: 60_000,
176− bucketMs: 10_000,
177− lastError: null,
178− });
179−
180− expect(result.topProcesses).toHaveLength(1);
181− expect(result.topProcesses[0]?.isServerRoot).toBe(true);
182− expect(result.topProcesses[0]?.sampleCount).toBe(2);
183− expect(result.topProcesses[0]?.maxRssBytes).toBe(2_000);
184− }),
185− );
186−
187− it.effect("returns all process summaries in the selected window", () =>
188− Effect.sync(() => {
189− const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
190− const samples = collectMonitoredSamples({
191− serverPid: 100,
192− sampledAt,
193− sampledAtMs: DateTime.toEpochMillis(sampledAt),
194− rows: [
34+ topProcesses: [
19535 {
196− pid: 100,
36+ identity: { pid: process.pid, startTimeMs: 100 },
19737 ppid: 1,
198− pgid: 100,
199− status: "S",
200− cpuPercent: 1,
201− rssBytes: 1_000,
202− elapsed: "01:00",
38+ depth: 0,
39+ name: "node",
20340 command: "t3 server",
41+ category: "server",
42+ firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
43+ lastSeenAt: readAt,
44+ currentCpuPercent: 5,
45+ avgCpuPercent: 4,
46+ maxCpuPercent: 8,
47+ cpuTimeMs: 1_500,
48+ currentRssBytes: 2_048,
49+ peakRssBytes: 4_096,
50+ ioReadBytes: 1_024,
51+ ioWriteBytes: 2_048,
52+ ioSemantics: "storage",
53+ sampleCount: 2,
20454 },
205− ...Array.from({ length: 35 }, (_, index) => ({
206− pid: 200 + index,
207− ppid: index === 0 ? 100 : 199 + index,
208− pgid: 100,
209− status: "S",
210− cpuPercent: 35 - index,
211− rssBytes: 2_000 + index,
212− elapsed: "00:10",
213− command: `worker ${index}`,
214− })),
21555 ],
216− });
56+ health: {
57+ native: {
58+ status: "degraded",
59+ lastSampleAt: Option.some(readAt),
60+ lastError: Option.some("collector stalled"),
61+ },
62+ desktop: {
63+ status: "healthy",
64+ lastSampleAt: Option.some(readAt),
65+ lastError: Option.none(),
66+ },
67+ sidecarVersion: Option.some("0.1.0"),
68+ sidecarPid: Option.some(9_000),
69+ restartCount: 1,
70+ collectionDurationMicros: 250,
71+ scannedProcessCount: 80,
72+ retainedProcessCount: 2,
73+ inaccessibleProcessCount: 0,
74+ },
75+ };
76+ const telemetry: ResourceTelemetry.ResourceTelemetryShape = {
77+ latest: Effect.die("unused"),
78+ changes: Stream.empty,
79+ readHistory: () => Effect.succeed(history),
80+ refresh: Effect.die("unused"),
81+ validateProcessIdentity: () => Effect.die("unused"),
82+ retry: Effect.die("unused"),
83+ };
84+ const layer = ProcessResourceMonitor.layer.pipe(
85+ Layer.provide(
86+ Layer.succeed(
87+ ResourceTelemetry.ResourceTelemetry,
88+ ResourceTelemetry.ResourceTelemetry.of(telemetry),
89+ ),
90+ ),
91+ );
21792
218− const result = aggregateProcessResourceHistory({
219− samples,
220− readAt: sampledAt,
221− readAtMs: DateTime.toEpochMillis(sampledAt),
222− windowMs: 60_000,
223− bucketMs: 10_000,
224− lastError: null,
225− });
93+ const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe(
94+ Effect.flatMap((monitor) =>
95+ monitor.readHistory({
96+ windowMs: 60_000,
97+ bucketMs: 10_000,
98+ }),
99+ ),
100+ Effect.provide(layer),
101+ );
226102
227− expect(result.topProcesses).toHaveLength(36);
228− expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true);
103+ expect(result.totalCpuSecondsApprox).toBe(1.5);
104+ expect(result.topProcesses).toEqual([
105+ {
106+ processKey: `${process.pid}:100`,
107+ pid: process.pid,
108+ ppid: 1,
109+ command: "t3 server",
110+ depth: 0,
111+ isServerRoot: true,
112+ firstSeenAt: DateTime.makeUnsafe("2026-05-05T09:59:55.000Z"),
113+ lastSeenAt: readAt,
114+ currentCpuPercent: 5,
115+ avgCpuPercent: 4,
116+ maxCpuPercent: 8,
117+ cpuSecondsApprox: 1.5,
118+ currentRssBytes: 2_048,
119+ maxRssBytes: 4_096,
120+ sampleCount: 2,
121+ },
122+ ]);
123+ expect(result.buckets[0]).toMatchObject({
124+ avgCpuPercent: 15,
125+ maxCpuPercent: 25,
126+ maxRssBytes: 4_096,
127+ maxProcessCount: 2,
128+ });
129+ expect(result.error).toEqual(Option.some({ message: "collector stalled" }));
229130 }),
230131 );
231132 });