-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathmetrics-sampler.ts
More file actions
216 lines (188 loc) · 6.86 KB
/
Copy pathmetrics-sampler.ts
File metadata and controls
216 lines (188 loc) · 6.86 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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
import fs from 'node:fs';
import { constants as perfConstants, PerformanceObserver } from 'node:perf_hooks';
import v8 from 'node:v8';
import { Logger } from '@pgpmjs/logger';
import { getCacheCounters, getCacheStats } from 'graphile-cache';
import { getBuildQueueDepth, getGraphileCounters } from '../middleware/graphile';
import { getIntrospectionFilterCounters } from '../middleware/introspection-filter';
import { getConnectionErrorGuardCounters } from './connection-error-guard';
const log = new Logger('metrics-sampler');
const DEFAULT_INTERVAL_MS = 10_000;
const DEFAULT_METRICS_FILE = './metrics.jsonl';
// =============================================================================
// GC tracking (perf_hooks)
//
// A single process-global PerformanceObserver accumulates GC pause time and counts
// by kind. Registered lazily the first time the sampler starts (so there is zero
// overhead when GRAPHILE_DEBUG_METRICS is off) and torn down when the sampler stops.
// =============================================================================
interface GcKindStat {
count: number;
totalPauseMs: number;
}
type GcStats = Record<string, GcKindStat>;
const gcStats: GcStats = {};
let gcObserver: PerformanceObserver | null = null;
// Map perf_hooks GC kind flags to stable, human-readable names for the metrics line.
const GC_KIND_NAMES: Record<number, string> = {
[perfConstants.NODE_PERFORMANCE_GC_MINOR]: 'minor',
[perfConstants.NODE_PERFORMANCE_GC_MAJOR]: 'major',
[perfConstants.NODE_PERFORMANCE_GC_INCREMENTAL]: 'incremental',
[perfConstants.NODE_PERFORMANCE_GC_WEAKCB]: 'weakcb'
};
const startGcObserver = (): void => {
if (gcObserver) {
return;
}
try {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// `detail.kind` is the modern accessor; fall back to the deprecated top-level
// `kind` for older Node. Guard: unknown kinds are bucketed by their numeric flag.
const kind =
(entry as { detail?: { kind?: number } }).detail?.kind ??
(entry as unknown as { kind?: number }).kind;
const name =
kind != null && GC_KIND_NAMES[kind] ? GC_KIND_NAMES[kind] : `kind_${kind ?? 'unknown'}`;
const stat = gcStats[name] ?? (gcStats[name] = { count: 0, totalPauseMs: 0 });
stat.count += 1;
stat.totalPauseMs += entry.duration;
}
});
observer.observe({ entryTypes: ['gc'] });
gcObserver = observer;
} catch (err) {
// GC observation is best-effort; a platform without 'gc' entries just omits GC data.
gcObserver = null;
log.warn('GC PerformanceObserver unavailable; metrics will omit GC data', err);
}
};
const stopGcObserver = (): void => {
if (gcObserver) {
try {
gcObserver.disconnect();
} catch {
// ignore — teardown is best-effort
}
gcObserver = null;
}
};
const snapshotGcStats = (): GcStats => {
const out: GcStats = {};
for (const [name, stat] of Object.entries(gcStats)) {
out[name] = { count: stat.count, totalPauseMs: stat.totalPauseMs };
}
return out;
};
// =============================================================================
// Env parsing
// =============================================================================
const isEnabled = (): boolean => {
const raw = process.env.GRAPHILE_DEBUG_METRICS;
if (!raw) {
return false;
}
const normalized = raw.trim().toLowerCase();
return normalized === '1' || normalized === 'true';
};
const getIntervalMs = (): number => {
const raw = process.env.GRAPHILE_DEBUG_METRICS_INTERVAL_MS;
const parsed = raw ? Number.parseInt(raw, 10) : DEFAULT_INTERVAL_MS;
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_INTERVAL_MS;
};
const getMetricsFile = (): string =>
process.env.GRAPHILE_DEBUG_METRICS_FILE || DEFAULT_METRICS_FILE;
// =============================================================================
// Sample
// =============================================================================
export interface MetricsSample {
ts: string;
rss: number;
heapUsed: number;
heapTotal: number;
external: number;
heap_size_limit: number;
cache: {
size: number;
keys: number;
};
counters: ReturnType<typeof getCacheCounters> &
ReturnType<typeof getGraphileCounters> & {
buildQueueDepth: number;
connGuard: ReturnType<typeof getConnectionErrorGuardCounters>;
introspectionFilter: ReturnType<typeof getIntrospectionFilterCounters>;
};
gc: GcStats;
}
/**
* Collect a single metrics sample. Cheap: a memoryUsage() call, a v8 heap-stats read,
* cache size/key-count, and integer counter snapshots. Exposed for tests and future
* promotion to a /metrics endpoint.
*/
export const collectMetricsSample = (): MetricsSample => {
const mem = process.memoryUsage();
const cacheStats = getCacheStats();
return {
ts: new Date().toISOString(),
rss: mem.rss,
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal,
external: mem.external,
heap_size_limit: v8.getHeapStatistics().heap_size_limit,
cache: {
size: cacheStats.size,
keys: cacheStats.keys.length
},
counters: {
...getCacheCounters(),
...getGraphileCounters(),
buildQueueDepth: getBuildQueueDepth(),
connGuard: getConnectionErrorGuardCounters(),
introspectionFilter: getIntrospectionFilterCounters()
},
gc: snapshotGcStats()
};
};
// =============================================================================
// Sampler
// =============================================================================
export interface MetricsSamplerHandle {
stop(): void;
}
/**
* Start the in-process metrics sampler.
*
* When GRAPHILE_DEBUG_METRICS is '1' or 'true', appends one JSON line per
* GRAPHILE_DEBUG_METRICS_INTERVAL_MS (default 10000ms) to GRAPHILE_DEBUG_METRICS_FILE
* (default ./metrics.jsonl). The interval timer is unref'd so it never keeps the
* process alive, and file writes swallow errors so metrics can never affect the server.
*
* Returns null (a true no-op, zero overhead) when disabled.
*/
export const startMetricsSampler = (): MetricsSamplerHandle | null => {
if (!isEnabled()) {
return null;
}
startGcObserver();
const intervalMs = getIntervalMs();
const file = getMetricsFile();
const writeSample = (): void => {
const line = `${JSON.stringify(collectMetricsSample())}\n`;
// Fire-and-forget; a failed metrics write must never impact request handling.
fs.appendFile(file, line, () => {
// swallow errors
});
};
// Emit an immediate baseline sample so the file has a line without waiting a full
// interval, then sample periodically.
writeSample();
const timer = setInterval(writeSample, intervalMs);
timer.unref();
log.info(`Metrics sampler writing every ${intervalMs}ms to ${file}`);
return {
stop(): void {
clearInterval(timer);
stopGcObserver();
}
};
};