|
| 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