Repository navigation
Expand file tree
/
Copy pathProcessGroupMetrics.ts
More file actions
145 lines (136 loc) · 6.18 KB
/
Copy pathProcessGroupMetrics.ts
File metadata and controls
145 lines (136 loc) · 6.18 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import type { ResourceTelemetryProcess } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Metric from "effect/Metric";
import * as Ref from "effect/Ref";
import * as ServerConfig from "../config.ts";
import { metricAttributes } from "../observability/Metrics.ts";
import * as ResourceTelemetry from "./ResourceTelemetry.ts";
const SAMPLE_INTERVAL = "1 minute";
const residentBytes = Metric.gauge("t3_process_group_resident_bytes", {
description: "Resident memory of the server's processes, summed by group.",
attributes: { unit: "By" },
});
const cpuPercent = Metric.gauge("t3_process_group_cpu_percent", {
description: "CPU use of the server's processes, summed by group; 100 is one full core.",
attributes: { unit: "%" },
});
const processCount = Metric.gauge("t3_process_group_count", {
description: "Processes in each group.",
attributes: { unit: "{process}" },
});
// Each direct child of the server names the group of its whole subtree. A fixed set keeps one
// series per group per server, however many agents and processes come and go.
const PROVIDER_GROUPS: ReadonlyArray<readonly [RegExp, string]> = [
[/codex/, "codex"],
[/claude/, "claude"],
[/cursor/, "cursor"],
[/opencode/, "opencode"],
[/grok/, "grok"],
[/antigravity|gemini/, "antigravity"],
];
const OTHER_GROUPS: ReadonlyArray<readonly [RegExp, string]> = [
[/chrom/, "browser"],
[/^(?:ba|z|fi|da|c)?sh(?:\.exe)?$|^pwsh|^powershell|^cmd\.exe$/, "terminal"],
];
// A CLI shipped as a script runs under a JavaScript runtime, so its process name is the
// runtime's (older Claude packages run `node .../@anthropic-ai/claude-code/cli.js`). Only those
// are matched by command line; any other name is matched by name alone, so a shell or `git`
// whose arguments mention a provider stays in its own group.
const SCRIPT_RUNTIME = /^(?:node|bun|deno)(?:\.exe)?$/;
const match = (groups: ReadonlyArray<readonly [RegExp, string]>, text: string) =>
groups.find(([pattern]) => pattern.test(text))?.[1];
const childGroup = (name: string, command: string) => {
const lowerName = name.toLowerCase();
if (SCRIPT_RUNTIME.test(lowerName))
return match(PROVIDER_GROUPS, command.toLowerCase()) ?? "other";
return match(PROVIDER_GROUPS, lowerName) ?? match(OTHER_GROUPS, lowerName) ?? "other";
};
export interface ProcessGroupTotals {
readonly residentBytes: number;
readonly cpuPercent: number;
readonly count: number;
}
export function groupProcesses(
processes: ReadonlyArray<ResourceTelemetryProcess>,
): ReadonlyMap<string, ProcessGroupTotals> {
const byPid = new Map(processes.map((process) => [process.identity.pid, process]));
const serverPid = processes.find((process) => process.category === "server")?.identity.pid;
const groupOf = (process: ResourceTelemetryProcess): string => {
if (process.category === "server") return "server";
if (process.category === "resource-monitor") return "resource-monitor";
if (process.category.startsWith("electron-")) return "electron";
let current = process;
// Bounded by the table size, in case a reused PID makes the parent chain loop.
for (let step = 0; step < processes.length; step++) {
if (current.ppid === serverPid) return childGroup(current.name, current.command);
const parent = byPid.get(current.ppid);
if (parent === undefined) break;
current = parent;
}
return "other";
};
const totals = new Map<string, ProcessGroupTotals>();
for (const process of processes) {
const group = groupOf(process);
const total = totals.get(group) ?? { residentBytes: 0, cpuPercent: 0, count: 0 };
totals.set(group, {
residentBytes: total.residentBytes + process.residentBytes,
cpuPercent: total.cpuPercent + process.cpuPercent,
count: total.count + 1,
});
}
return totals;
}
/**
* Exports process totals by group while OTLP metrics export is on. Sampling walks the process
* table, which an idle server otherwise never does, so only a server exporting metrics pays for
* it, once a minute.
*/
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
if (config.otlpMetricsUrl === undefined) return;
const telemetry = yield* ResourceTelemetry.ResourceTelemetry;
// A group that empties reads zero rather than holding its last value.
const seen = new Set<string>();
const sample = Effect.fn("ProcessGroupMetrics.sample")(function* () {
const totals = groupProcesses((yield* telemetry.refresh).processes);
for (const group of totals.keys()) seen.add(group);
for (const group of seen) {
const total = totals.get(group) ?? { residentBytes: 0, cpuPercent: 0, count: 0 };
const attributes = metricAttributes({ group });
yield* Metric.update(Metric.withAttributes(residentBytes, attributes), total.residentBytes);
yield* Metric.update(Metric.withAttributes(cpuPercent, attributes), total.cpuPercent);
yield* Metric.update(Metric.withAttributes(processCount, attributes), total.count);
}
});
// A failing collector would otherwise leave these series silently absent, so the first
// failure and the recovery are logged; the repeats stay at debug.
const failing = yield* Ref.make(false);
const recordSuccess = Ref.getAndSet(failing, false).pipe(
Effect.flatMap((wasFailing) =>
wasFailing ? Effect.logInfo("process group metrics sample recovered") : Effect.void,
),
);
const recordFailure = (error: ResourceTelemetry.ResourceTelemetryRefreshFailed) => {
const cause = Cause.fail(error);
return Ref.getAndSet(failing, true).pipe(
Effect.flatMap((wasFailing) =>
wasFailing
? Effect.logDebug("process group metrics sample failed", cause)
: Effect.logWarning("process group metrics are not exported: sample failed", cause),
),
Effect.annotateLogs({ operation: error.operation }),
);
};
yield* sample().pipe(
Effect.andThen(recordSuccess),
Effect.catch(recordFailure),
Effect.andThen(Effect.sleep(SAMPLE_INTERVAL)),
Effect.forever,
Effect.forkScoped,
);
}),
);