| author | |
| committer | |
| log | bac35a2ffcf90bb9fac7f63582bda9c549fd75ff |
| tree | 0d7c74c949b46177091aa22e49c7bc4fce883bb0 |
| parent | 8855a6dd052a8cbc9aac125dc2e8d14a0c2decd6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: SAPHID <saphid@users.noreply.github.com>
Co-authored-by: GPT-6.1 Sol <noreply@openai.com>3 files changed, 92 insertions(+), 13 deletions(-)
apps/server/src/resourceTelemetry/Model.ts+11-8| ... | @@ -52,6 +52,8 @@ export interface MergeProcessesInput { | ... | @@ -52,6 +52,8 @@ export interface MergeProcessesInput { |
| 52 | readonly previous: ReadonlyMap<string, ProcessState>; | 52 | readonly previous: ReadonlyMap<string, ProcessState>; |
| 53 | readonly counters: TelemetryCounters; | 53 | readonly counters: TelemetryCounters; |
| 54 | readonly updatePrevious: boolean; | 54 | readonly updatePrevious: boolean; |
| 55 | /** History replay needs process deltas, but does not publish lifetime group counters. */ | ||
| 56 | readonly accumulateCounters?: boolean; | ||
| 55 | } | 57 | } |
| 56 | 58 | ||
| 57 | export interface MergeProcessesResult { | 59 | export interface MergeProcessesResult { |
| ... | @@ -574,14 +576,15 @@ export function mergeProcesses(input: MergeProcessesInput): MergeProcessesResult | ... | @@ -574,14 +576,15 @@ export function mergeProcesses(input: MergeProcessesInput): MergeProcessesResult |
| 574 | }); | 576 | }); |
| 575 | const ordered = orderProcessTree(normalized, rootPids); | 577 | const ordered = orderProcessTree(normalized, rootPids); |
| 576 | 578 | ||
| 577 | const counters = input.updatePrevious | 579 | const counters = |
| 578 | ? applyLifecycleCounters({ | 580 | input.updatePrevious && input.accumulateCounters !== false |
| 579 | counters: input.counters, | 581 | ? applyLifecycleCounters({ |
| 580 | deltas: processDeltas, | 582 | counters: input.counters, |
| 581 | current: nextPrevious, | 583 | deltas: processDeltas, |
| 582 | previous: input.previous, | 584 | current: nextPrevious, |
| 583 | }) | 585 | previous: input.previous, |
| 584 | : input.counters; | 586 | }) |
| 587 | : input.counters; | ||
| 585 | const backendProcesses = ordered.filter( | 588 | const backendProcesses = ordered.filter( |
| 586 | (process) => categoryGroup(process.category) === "backend", | 589 | (process) => categoryGroup(process.category) === "backend", |
| 587 | ); | 590 | ); |
apps/server/src/resourceTelemetry/ResourceTelemetry.test.ts+74| ... | @@ -142,6 +142,80 @@ function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot | ... | @@ -142,6 +142,80 @@ function desktopSnapshot(sampledAtUnixMs: number): DesktopHostTelemetrySnapshot |
| 142 | } | 142 | } |
| 143 | 143 | ||
| 144 | describe("ResourceTelemetry", () => { | 144 | describe("ResourceTelemetry", () => { |
| 145 | it.effect("replays churning history and retains baselines for returning processes", () => | ||
| 146 | Effect.gen(function* () { | ||
| 147 | const readAtMs = DateTime.toEpochMillis(yield* DateTime.now); | ||
| 148 | const sampleCount = 100; | ||
| 149 | const samples = Array.from({ length: sampleCount }, (_, index) => { | ||
| 150 | const sample = nativeSnapshot({ | ||
| 151 | sequence: index + 1, | ||
| 152 | sampledAtUnixMs: readAtMs - (sampleCount - index) * 1_000, | ||
| 153 | childCpuTimeMs: index * 10, | ||
| 154 | childWriteBytes: index * 100, | ||
| 155 | }); | ||
| 156 | const processes = sample.processes | ||
| 157 | .filter((entry) => entry.pid === process.pid || entry.pid === 4_242) | ||
| 158 | .map((entry) => | ||
| 159 | entry.pid === 4_242 | ||
| 160 | ? { ...entry, startTimeMs: index } | ||
| 161 | : { ...entry, ioWriteBytes: index * 100 }, | ||
| 162 | ); | ||
| 163 | if (index === 0 || index === 2) { | ||
| 164 | processes.push( | ||
| 165 | processSample({ | ||
| 166 | pid: 4_243, | ||
| 167 | ppid: process.pid, | ||
| 168 | startTimeMs: 500, | ||
| 169 | cpuTimeMs: index === 0 ? 100 : 350, | ||
| 170 | ioWriteBytes: index === 0 ? 1_000 : 5_000, | ||
| 171 | }), | ||
| 172 | ); | ||
| 173 | } | ||
| 174 | return { ...sample, processes }; | ||
| 175 | }); | ||
| 176 | const layerTelemetry = ResourceTelemetry.layer.pipe( | ||
| 177 | Layer.provide( | ||
| 178 | Layer.mergeAll( | ||
| 179 | NativeTelemetryClient.layerTest({ readHistory: () => Effect.succeed(samples) }), | ||
| 180 | DesktopTelemetryReceiver.layerTest(), | ||
| 181 | ResourceAttribution.layer, | ||
| 182 | ), | ||
| 183 | ), | ||
| 184 | ); | ||
| 185 | |||
| 186 | yield* Effect.gen(function* () { | ||
| 187 | const telemetry = yield* ResourceTelemetry.ResourceTelemetry; | ||
| 188 | const history = yield* telemetry.readHistory({ | ||
| 189 | windowMs: sampleCount * 1_000, | ||
| 190 | bucketMs: 10_000, | ||
| 191 | }); | ||
| 192 | |||
| 193 | expect(history.topProcesses).toHaveLength(sampleCount + 2); | ||
| 194 | expect( | ||
| 195 | history.topProcesses.find((entry) => entry.identity.pid === process.pid), | ||
| 196 | ).toMatchObject({ | ||
| 197 | sampleCount, | ||
| 198 | cpuTimeMs: (sampleCount - 1) * 10, | ||
| 199 | ioWriteBytes: (sampleCount - 1) * 100, | ||
| 200 | }); | ||
| 201 | const children = history.topProcesses.filter((entry) => entry.identity.pid === 4_242); | ||
| 202 | expect(children).toHaveLength(sampleCount); | ||
| 203 | for (const child of children) { | ||
| 204 | expect(child).toMatchObject({ sampleCount: 1, cpuTimeMs: 0, ioWriteBytes: 0 }); | ||
| 205 | } | ||
| 206 | expect(history.topProcesses.find((entry) => entry.identity.pid === 4_243)).toMatchObject({ | ||
| 207 | sampleCount: 2, | ||
| 208 | cpuTimeMs: 250, | ||
| 209 | ioWriteBytes: 4_000, | ||
| 210 | }); | ||
| 211 | expect(Math.max(...history.buckets.map((bucket) => bucket.maxProcessCount))).toBe(3); | ||
| 212 | expect(history.buckets.reduce((total, bucket) => total + bucket.ioWriteBytes, 0)).toBe( | ||
| 213 | (sampleCount - 1) * 100 + 4_000, | ||
| 214 | ); | ||
| 215 | }).pipe(Effect.provide(layerTelemetry)); | ||
| 216 | }), | ||
| 217 | ); | ||
| 218 | |||
| 145 | it.effect("enables live native and Electron collection only while changes are retained", () => | 219 | it.effect("enables live native and Electron collection only while changes are retained", () => |
| 146 | Effect.gen(function* () { | 220 | Effect.gen(function* () { |
| 147 | const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); | 221 | const sampledAtUnixMs = DateTime.toEpochMillis(yield* DateTime.now); |
apps/server/src/resourceTelemetry/ResourceTelemetryHistory.ts+7-5| ... | @@ -15,7 +15,6 @@ import { | ... | @@ -15,7 +15,6 @@ import { |
| 15 | mergeProcesses, | 15 | mergeProcesses, |
| 16 | processIdentityKey, | 16 | processIdentityKey, |
| 17 | type ProcessState, | 17 | type ProcessState, |
| 18 | type TelemetryCounters, | ||
| 19 | } from "./Model.ts"; | 18 | } from "./Model.ts"; |
| 20 | 19 | ||
| 21 | const MAX_HISTORY_WINDOW_MS = 60 * 60_000; | 20 | const MAX_HISTORY_WINDOW_MS = 60 * 60_000; |
| ... | @@ -167,8 +166,8 @@ export function buildResourceTelemetryHistory( | ... | @@ -167,8 +166,8 @@ export function buildResourceTelemetryHistory( |
| 167 | const aggregateSamples: AggregateSample[] = []; | 166 | const aggregateSamples: AggregateSample[] = []; |
| 168 | const legacyBackendAggregateSamples: AggregateSample[] = []; | 167 | const legacyBackendAggregateSamples: AggregateSample[] = []; |
| 169 | const processSamples: ProcessSample[] = []; | 168 | const processSamples: ProcessSample[] = []; |
| 170 | let previous: ReadonlyMap<string, ProcessState> = new Map(); | 169 | const previous = new Map<string, ProcessState>(); |
| 171 | let counters: TelemetryCounters = emptyTelemetryCounters(); | 170 | const counters = emptyTelemetryCounters(); |
| 172 | let previousSnapshotAtMs: number | undefined; | 171 | let previousSnapshotAtMs: number | undefined; |
| 173 | 172 | ||
| 174 | for (const snapshot of snapshots) { | 173 | for (const snapshot of snapshots) { |
| ... | @@ -216,9 +215,12 @@ export function buildResourceTelemetryHistory( | ... | @@ -216,9 +215,12 @@ export function buildResourceTelemetryHistory( |
| 216 | previous, | 215 | previous, |
| 217 | counters, | 216 | counters, |
| 218 | updatePrevious: true, | 217 | updatePrevious: true, |
| 218 | accumulateCounters: false, | ||
| 219 | }); | 219 | }); |
| 220 | previous = new Map([...previous, ...merged.previous]); | 220 | // Keep absent identities' baselines without copying every exited process on each sample. |
| 221 | counters = merged.counters; | 221 | for (const [identityKey, state] of merged.previous) { |
| 222 | previous.set(identityKey, state); | ||
| 223 | } | ||
| 222 | if (snapshot.sampledAtUnixMs < windowStartMs) { | 224 | if (snapshot.sampledAtUnixMs < windowStartMs) { |
| 223 | continue; | 225 | continue; |
| 224 | } | 226 | } |