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 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import type { ResourceTelemetryHistory } from "@t3tools/contracts";
2import * as DateTime from "effect/DateTime";3import * as DateTime from "effect/DateTime";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import * as Stream from "effect/Stream";
5 8
6import {9import * as ResourceTelemetry from "../resourceTelemetry/ResourceTelemetry.ts";
7 aggregateProcessResourceHistory,10import * as ProcessResourceMonitor from "./ProcessResourceMonitor.ts";
8 collectMonitoredSamples,
9} from "./ProcessResourceMonitor.ts";
10 11
11describe("ProcessResourceMonitor", () => {12describe("ProcessResourceMonitor", () => {
12 it.effect("samples the server root process and descendants", () =>13 it.effect("projects resource telemetry history into the legacy diagnostics contract", () =>
13 Effect.sync(() => {14 Effect.gen(function* () {
14 const sampledAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");15 const readAt = DateTime.makeUnsafe("2026-05-05T10:00:00.000Z");
15 const samples = collectMonitoredSamples({16 const history: ResourceTelemetryHistory = {
16 serverPid: 100,17 readAt,
17 sampledAt,18 windowMs: 60_000,
18 sampledAtMs: DateTime.toEpochMillis(sampledAt),19 bucketMs: 10_000,
19 rows: [20 sampleIntervalMs: 1_000,
20 {21 retainedSampleCount: 2,
21 pid: 100,22 buckets: [
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 },
50 {23 {
51 pid: 200,24 startedAt: DateTime.makeUnsafe("2026-05-05T09:59:50.000Z"),
52 ppid: 1,25 endedAt: readAt,
53 pgid: 200,26 avgCpuPercent: 15,
54 status: "R",27 maxCpuPercent: 25,
55 cpuPercent: 99,28 maxRssBytes: 4_096,
56 rssBytes: 9_000,29 ioReadBytes: 1_024,
57 elapsed: "00:05",30 ioWriteBytes: 2_048,
58 command: "unrelated",31 maxProcessCount: 2,
59 },32 },
60 ],33 ],
61 });34 topProcesses: [
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: [
195 {35 {
196 pid: 100,36 identity: { pid: process.pid, startTimeMs: 100 },
197 ppid: 1,37 ppid: 1,
198 pgid: 100,38 depth: 0,
199 status: "S",39 name: "node",
200 cpuPercent: 1,
201 rssBytes: 1_000,
202 elapsed: "01:00",
203 command: "t3 server",40 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,
204 },54 },
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 })),
215 ],55 ],
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 );
217 92
218 const result = aggregateProcessResourceHistory({93 const result = yield* Effect.service(ProcessResourceMonitor.ProcessResourceMonitor).pipe(
219 samples,94 Effect.flatMap((monitor) =>
220 readAt: sampledAt,95 monitor.readHistory({
221 readAtMs: DateTime.toEpochMillis(sampledAt),96 windowMs: 60_000,
222 windowMs: 60_000,97 bucketMs: 10_000,
223 bucketMs: 10_000,98 }),
224 lastError: null,99 ),
225 });100 Effect.provide(layer),
101 );
226 102
227 expect(result.topProcesses).toHaveLength(36);103 expect(result.totalCpuSecondsApprox).toBe(1.5);
228 expect(result.topProcesses.some((process) => process.command === "worker 34")).toBe(true);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" }));
229 }),130 }),
230 );131 );
231});132});