Skip to content

Commit f28380f

Browse files
committed
test: cover HTTP histogram metrics via gRPC
Add a dedicated test that generates HTTP client and server traffic, then asserts on the exported histogram data over gRPC/OTLP. Validate bucket counts and attributes to ensure the new HTTP latency histogram pipeline is exercised end to end.
1 parent f65aac4 commit f28380f

2 files changed

Lines changed: 280 additions & 0 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,3 +180,6 @@ compile_commands.json
180180
# Ignore asserts-cpp and nlohmann paths in src/
181181
src/asserts-cpp/
182182
src/nlohmann/
183+
184+
test/integrations/express/*/node_modules/
185+
test/integrations/fastify/*/node_modules/
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
// Flags: --expose-internals
2+
import { mustCallAtLeast, mustSucceed } from '../common/index.mjs';
3+
import assert from 'node:assert';
4+
import {
5+
GRPCServer,
6+
TestClient,
7+
} from '../common/nsolid-grpc-agent/index.js';
8+
import validators from 'internal/validators';
9+
10+
const {
11+
validateArray,
12+
validateNumber,
13+
} = validators;
14+
15+
// Expected exponential histogram metrics exported by the GrpcAgent.
16+
// The GrpcAgent uses use_snake_case=false, so names are camelCase.
17+
const expectedHistograms = [
18+
['http.client.request.duration', 's'],
19+
['http.server.request.duration', 's'],
20+
];
21+
22+
// Semconv attribute keys expected on each data point (besides thread attrs).
23+
const commonHttpAttrs = [
24+
'http.request.method',
25+
'http.response.status_code',
26+
'network.protocol.version',
27+
];
28+
const clientOnlyAttrs = ['server.address'];
29+
const serverOnlyAttrs = ['url.scheme'];
30+
31+
function getAttr(attributes, key) {
32+
return attributes.find((a) => a.key === key);
33+
}
34+
35+
function checkExponentialHistogramDataPoint(name, dataPoint) {
36+
// Validate timestamps.
37+
const startTime = BigInt(dataPoint.startTimeUnixNano);
38+
assert.ok(startTime, `${name}: startTimeUnixNano should be set`);
39+
const time = BigInt(dataPoint.timeUnixNano);
40+
assert.ok(time, `${name}: timeUnixNano should be set`);
41+
assert.ok(time > startTime, `${name}: timeUnixNano > startTimeUnixNano`);
42+
43+
// Validate attributes.
44+
validateArray(dataPoint.attributes, `${name}.attributes`);
45+
46+
// Thread attributes.
47+
const threadIdAttr = getAttr(dataPoint.attributes, 'thread.id');
48+
assert.ok(threadIdAttr, `${name}: should have thread.id attribute`);
49+
assert.strictEqual(threadIdAttr.value.intValue, '0');
50+
const threadNameAttr = getAttr(dataPoint.attributes, 'thread.name');
51+
assert.ok(threadNameAttr, `${name}: should have thread.name attribute`);
52+
53+
// Common HTTP semconv attributes.
54+
for (const key of commonHttpAttrs) {
55+
assert.ok(getAttr(dataPoint.attributes, key),
56+
`${name}: should have ${key} attribute`);
57+
}
58+
59+
// Validate http.request.method value.
60+
const methodAttr = getAttr(dataPoint.attributes, 'http.request.method');
61+
assert.strictEqual(methodAttr.value.stringValue, 'GET',
62+
`${name}: http.request.method should be GET`);
63+
64+
// Validate http.response.status_code value.
65+
const statusAttr = getAttr(dataPoint.attributes, 'http.response.status_code');
66+
assert.strictEqual(statusAttr.value.intValue, '200',
67+
`${name}: http.response.status_code should be 200`);
68+
69+
// Validate network.protocol.version value.
70+
const versionAttr = getAttr(dataPoint.attributes, 'network.protocol.version');
71+
assert.strictEqual(versionAttr.value.stringValue, '1.1',
72+
`${name}: network.protocol.version should be 1.1`);
73+
74+
// Type-specific attributes.
75+
const isClient = name.includes('Client') || name.includes('client');
76+
if (isClient) {
77+
for (const key of clientOnlyAttrs) {
78+
assert.ok(getAttr(dataPoint.attributes, key),
79+
`${name}: client should have ${key} attribute`);
80+
}
81+
const addrAttr = getAttr(dataPoint.attributes, 'server.address');
82+
assert.strictEqual(addrAttr.value.stringValue, '127.0.0.1',
83+
`${name}: server.address should be 127.0.0.1`);
84+
} else {
85+
for (const key of serverOnlyAttrs) {
86+
assert.ok(getAttr(dataPoint.attributes, key),
87+
`${name}: server should have ${key} attribute`);
88+
}
89+
const schemeAttr = getAttr(dataPoint.attributes, 'url.scheme');
90+
assert.strictEqual(schemeAttr.value.stringValue, 'http',
91+
`${name}: url.scheme should be http`);
92+
}
93+
94+
// Validate histogram fields: count, sum, scale.
95+
// count is a string (uint64 via proto longs: String).
96+
const count = parseInt(dataPoint.count, 10);
97+
assert.ok(count > 0, `${name}: count should be > 0, got ${count}`);
98+
99+
// 'sum' should be present and > 0 (latency values are positive).
100+
validateNumber(dataPoint.sum, `${name}.sum`);
101+
assert.ok(dataPoint.sum > 0, `${name}: sum should be > 0`);
102+
103+
// 'scale' should be a number.
104+
validateNumber(dataPoint.scale, `${name}.scale`);
105+
106+
// 'positive' buckets should have data (latency is always positive).
107+
assert.ok(dataPoint.positive, `${name}: positive buckets should exist`);
108+
validateNumber(dataPoint.positive.offset, `${name}.positive.offset`);
109+
validateArray(dataPoint.positive.bucketCounts, `${name}.positive.bucketCounts`);
110+
assert.ok(dataPoint.positive.bucketCounts.length > 0, `${name}: positive.bucketCounts should not be empty`);
111+
112+
// 'min' and 'max' should be present and > 0.
113+
validateNumber(dataPoint.min, `${name}.min`);
114+
assert.ok(dataPoint.min > 0, `${name}: min should be > 0`);
115+
validateNumber(dataPoint.max, `${name}.max`);
116+
assert.ok(dataPoint.max > 0, `${name}: max should be > 0`);
117+
assert.ok(dataPoint.max >= dataPoint.min, `${name}: max should be >= min`);
118+
}
119+
120+
function checkHistogramMetrics(metricsData) {
121+
const resourceMetrics = metricsData.resourceMetrics;
122+
if (!resourceMetrics || resourceMetrics.length === 0) return null;
123+
124+
const scopeMetrics = resourceMetrics[0].scopeMetrics;
125+
if (!scopeMetrics || scopeMetrics.length === 0) return null;
126+
127+
const metrics = scopeMetrics[0].metrics;
128+
if (!metrics) return null;
129+
130+
// Find all exponential histogram metrics with count > 0.
131+
const remaining = [...expectedHistograms];
132+
for (const metric of metrics) {
133+
if (metric.data !== 'exponentialHistogram') continue;
134+
135+
const idx = remaining.findIndex((m) => m[0] === metric.name);
136+
if (idx === -1) continue;
137+
138+
const [name, unit] = remaining[idx];
139+
assert.strictEqual(metric.unit, unit, `${name}: unit should be '${unit}'`);
140+
141+
// Validate aggregation temporality (delta).
142+
assert.strictEqual(metric.exponentialHistogram.aggregationTemporality,
143+
'AGGREGATION_TEMPORALITY_DELTA',
144+
`${name}: should use delta temporality`);
145+
146+
const dataPoints = metric.exponentialHistogram.dataPoints;
147+
validateArray(dataPoints, `${name}.dataPoints`);
148+
assert.ok(dataPoints.length > 0, `${name}: should have at least one data point`);
149+
150+
// Find a data point with count > 0 (histogram has actual data).
151+
const dp = dataPoints.find((d) => parseInt(d.count, 10) > 0);
152+
if (dp) {
153+
checkExponentialHistogramDataPoint(name, dp);
154+
remaining.splice(idx, 1);
155+
}
156+
}
157+
158+
return remaining.length === 0;
159+
}
160+
161+
function collectHistogramWindows(metricsData) {
162+
const resourceMetrics = metricsData.resourceMetrics;
163+
if (!resourceMetrics || resourceMetrics.length === 0) {
164+
return new Map();
165+
}
166+
167+
const scopeMetrics = resourceMetrics[0].scopeMetrics;
168+
if (!scopeMetrics || scopeMetrics.length === 0) {
169+
return new Map();
170+
}
171+
172+
const metrics = scopeMetrics[0].metrics;
173+
if (!metrics) {
174+
return new Map();
175+
}
176+
177+
const windows = new Map();
178+
for (const metric of metrics) {
179+
if (metric.data !== 'exponentialHistogram') continue;
180+
181+
const expected = expectedHistograms.find((m) => m[0] === metric.name);
182+
if (!expected) continue;
183+
184+
const dataPoints = metric.exponentialHistogram.dataPoints;
185+
validateArray(dataPoints, `${metric.name}.dataPoints`);
186+
187+
// We keep HTTP traffic flowing, so each interval should carry data.
188+
const dp = dataPoints.find((d) => parseInt(d.count, 10) > 0);
189+
if (!dp) continue;
190+
191+
windows.set(metric.name, {
192+
start: BigInt(dp.startTimeUnixNano),
193+
end: BigInt(dp.timeUnixNano),
194+
});
195+
}
196+
197+
return windows;
198+
}
199+
200+
async function runTest({ getEnv }) {
201+
return new Promise((resolve) => {
202+
const grpcServer = new GRPCServer();
203+
grpcServer.start(mustSucceed(async (port) => {
204+
console.log('GRPC server started', port);
205+
const env = getEnv(port);
206+
const opts = {
207+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'],
208+
env,
209+
};
210+
const child = new TestClient([], opts);
211+
await child.id();
212+
await child.config({ app: 'histogram_test' });
213+
214+
// Prime histogram streams with initial requests.
215+
const NUM_HTTP_TRANSACTIONS = 5;
216+
for (let i = 0; i < NUM_HTTP_TRANSACTIONS; i++) {
217+
await child.trace('http');
218+
}
219+
220+
// Keep HTTP traffic flowing so each metrics interval has histogram data.
221+
const trafficTimer = setInterval(() => {
222+
child.trace('http').catch(() => {});
223+
}, 200);
224+
225+
const previousWindows = new Map();
226+
const continuityValidated = new Set();
227+
228+
// Listen for metrics until we find exponential histograms with data.
229+
let shutdownCalled = false;
230+
grpcServer.on('metrics', mustCallAtLeast((data) => {
231+
if (shutdownCalled) return;
232+
const done = checkHistogramMetrics(data);
233+
const windows = collectHistogramWindows(data);
234+
for (const [metricName, window] of windows) {
235+
const previous = previousWindows.get(metricName);
236+
if (previous) {
237+
assert.strictEqual(
238+
window.start,
239+
previous.end,
240+
`${metricName}: current startTimeUnixNano should match previous timeUnixNano`,
241+
);
242+
continuityValidated.add(metricName);
243+
}
244+
previousWindows.set(metricName, window);
245+
}
246+
247+
if (done && continuityValidated.size === expectedHistograms.length) {
248+
shutdownCalled = true;
249+
clearInterval(trafficTimer);
250+
console.log('All exponential histograms validated');
251+
child.shutdown(0).then(() => {
252+
grpcServer.close();
253+
resolve();
254+
});
255+
}
256+
}, 1));
257+
}));
258+
});
259+
}
260+
261+
const testConfigs = [
262+
{
263+
getEnv: (port) => {
264+
return {
265+
NODE_DEBUG_NATIVE: 'nsolid_grpc_agent',
266+
NSOLID_GRPC_INSECURE: 1,
267+
NSOLID_GRPC: `localhost:${port}`,
268+
NSOLID_INTERVAL: 1000,
269+
};
270+
},
271+
},
272+
];
273+
274+
for (const testConfig of testConfigs) {
275+
await runTest(testConfig);
276+
console.log('Test passed!');
277+
}

0 commit comments

Comments
 (0)