@platformatic/metrics 3.54.0 → 3.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import collectHttpMetrics from '@platformatic/http-metrics'
2
+ import { subscribe, unsubscribe } from 'node:diagnostics_channel'
2
3
  import os from 'node:os'
3
4
  import { performance } from 'node:perf_hooks'
4
5
  import client from '@platformatic/prom-client'
@@ -21,9 +22,17 @@ import gc from '@platformatic/prom-client/lib/metrics/gc.js'
21
22
  export * as client from '@platformatic/prom-client'
22
23
 
23
24
  const { eventLoopUtilization } = performance
24
- const { Registry, Gauge, Counter, collectDefaultMetrics } = client
25
+ const { Registry, Gauge, Counter, Histogram, collectDefaultMetrics } = client
25
26
 
26
27
  export const kMetricsGroups = Symbol('plt.metrics.MetricsGroups')
28
+ const kMetricsCleanups = Symbol('plt.metrics.MetricsCleanups')
29
+ const kHttpClientRequestStart = Symbol('plt.metrics.HttpClientRequestStart')
30
+ const kHttpClientRequestStatusCode = Symbol('plt.metrics.HttpClientRequestStatusCode')
31
+
32
+ function getRegistrySet (registry, key) {
33
+ registry[key] ??= new Set()
34
+ return registry[key]
35
+ }
27
36
 
28
37
  // Process-level metrics (same across all workers, collect once in main thread)
29
38
  export const PROCESS_LEVEL_METRICS = [
@@ -69,8 +78,7 @@ export const THREAD_LEVEL_METRICS = [
69
78
  ]
70
79
 
71
80
  export function registerMetricsGroup (registry, group) {
72
- registry[kMetricsGroups] ??= new Set()
73
- registry[kMetricsGroups].add(group)
81
+ getRegistrySet(registry, kMetricsGroups).add(group)
74
82
  }
75
83
 
76
84
  export function hasMetricsGroup (registry, group) {
@@ -80,23 +88,141 @@ export function hasMetricsGroup (registry, group) {
80
88
  // Use this method when dealing with metrics registration in async functions.
81
89
  // This will ensure that the group is registered only once.
82
90
  export function ensureMetricsGroup (registry, group) {
83
- registry[kMetricsGroups] ??= new Set()
91
+ const groups = getRegistrySet(registry, kMetricsGroups)
84
92
 
85
- if (registry[kMetricsGroups]?.has(group)) {
93
+ if (groups.has(group)) {
86
94
  return true
87
95
  }
88
96
 
89
- registry[kMetricsGroups].add(group)
97
+ groups.add(group)
90
98
  return false
91
99
  }
92
100
 
101
+ export function registerMetricsCleanup (registry, cleanup) {
102
+ getRegistrySet(registry, kMetricsCleanups).add(cleanup)
103
+ }
104
+
93
105
  export function clearRegistry (registry) {
106
+ if (registry[kMetricsCleanups]) {
107
+ for (const cleanup of registry[kMetricsCleanups]) {
108
+ cleanup()
109
+ }
110
+ registry[kMetricsCleanups].clear()
111
+ }
112
+
94
113
  registry.clear()
95
114
  if (registry[kMetricsGroups]) {
96
115
  registry[kMetricsGroups].clear()
97
116
  }
98
117
  }
99
118
 
119
+ function getHttpClientRequestOrigin (request) {
120
+ return typeof request.origin === 'string' && request.origin.length > 0 ? request.origin : 'unknown'
121
+ }
122
+
123
+ function getHttpClientErrorType (error) {
124
+ return error ? String(error.code ?? error.name ?? 'unknown') : ''
125
+ }
126
+
127
+ function isHttpClientMetricsEnabled (metricsConfig) {
128
+ return metricsConfig.httpClientMetrics === true || metricsConfig.httpClientMetrics === 'true'
129
+ }
130
+
131
+ export function collectHttpClientMetrics (registry) {
132
+ if (ensureMetricsGroup(registry, 'http-client')) {
133
+ return
134
+ }
135
+
136
+ const requestDurationMetric = new Histogram({
137
+ name: 'http_client_request_duration_seconds',
138
+ help: 'outgoing HTTP client request duration in seconds',
139
+ labelNames: ['method', 'status_code', 'dispatcher_stats_url', 'error_type'],
140
+ collect: function () {
141
+ process.nextTick(() => this.reset())
142
+ },
143
+ registers: [registry]
144
+ })
145
+
146
+ const onRequestCreate = ({ request }) => {
147
+ if (request && typeof request === 'object') {
148
+ request[kHttpClientRequestStart] = performance.now()
149
+ }
150
+ }
151
+
152
+ const onRequestHeaders = ({ request, response }) => {
153
+ if (request && typeof request === 'object') {
154
+ request[kHttpClientRequestStatusCode] = response?.statusCode ?? ''
155
+ }
156
+ }
157
+
158
+ const observeRequest = ({ request, response, error }) => {
159
+ if (!request || typeof request !== 'object') {
160
+ return
161
+ }
162
+
163
+ const start = request[kHttpClientRequestStart]
164
+ if (start === undefined) {
165
+ return
166
+ }
167
+
168
+ const duration = (performance.now() - start) / 1000
169
+ const method = request.method ?? 'unknown'
170
+ const statusCode = response?.statusCode ?? request[kHttpClientRequestStatusCode] ?? ''
171
+ const dispatcherStatsUrl = getHttpClientRequestOrigin(request)
172
+ const errorType = getHttpClientErrorType(error)
173
+
174
+ delete request[kHttpClientRequestStart]
175
+ delete request[kHttpClientRequestStatusCode]
176
+
177
+ requestDurationMetric.observe({
178
+ method,
179
+ status_code: statusCode,
180
+ dispatcher_stats_url: dispatcherStatsUrl,
181
+ error_type: errorType
182
+ }, duration)
183
+ }
184
+
185
+ subscribe('undici:request:create', onRequestCreate)
186
+ subscribe('undici:request:headers', onRequestHeaders)
187
+ subscribe('undici:request:trailers', observeRequest)
188
+ subscribe('undici:request:error', observeRequest)
189
+
190
+ registerMetricsCleanup(registry, () => {
191
+ unsubscribe('undici:request:create', onRequestCreate)
192
+ unsubscribe('undici:request:headers', onRequestHeaders)
193
+ unsubscribe('undici:request:trailers', observeRequest)
194
+ unsubscribe('undici:request:error', observeRequest)
195
+ })
196
+ }
197
+
198
+ function collectHttpServerMetrics (registry, metricsConfig) {
199
+ if (ensureMetricsGroup(registry, 'http')) {
200
+ return
201
+ }
202
+
203
+ // Build custom labels configuration
204
+ const { customLabels, getCustomLabels } = buildCustomLabelsConfig(metricsConfig.httpCustomLabels)
205
+
206
+ collectHttpMetrics(registry, {
207
+ customLabels,
208
+ getCustomLabels,
209
+ histogram: {
210
+ name: 'http_request_all_duration_seconds',
211
+ help: 'request duration in seconds summary for all requests',
212
+ collect: function () {
213
+ process.nextTick(() => this.reset())
214
+ }
215
+ },
216
+ summary: {
217
+ name: 'http_request_all_summary_seconds',
218
+ help: 'request duration in seconds histogram for all requests',
219
+ collect: function () {
220
+ process.nextTick(() => this.reset())
221
+ }
222
+ }
223
+ })
224
+ }
225
+
100
226
  export async function collectThreadCpuMetrics (registry) {
101
227
  if (ensureMetricsGroup(registry, 'threadCpuUsage')) {
102
228
  return
@@ -283,28 +409,11 @@ export async function collectThreadMetrics (applicationId, workerId, metricsConf
283
409
  await collectThreadCpuMetrics(registry)
284
410
  }
285
411
 
286
- if (metricsConfig.httpMetrics && !ensureMetricsGroup(registry, 'http')) {
287
- // Build custom labels configuration
288
- const { customLabels, getCustomLabels } = buildCustomLabelsConfig(metricsConfig.httpCustomLabels)
289
-
290
- collectHttpMetrics(registry, {
291
- customLabels,
292
- getCustomLabels,
293
- histogram: {
294
- name: 'http_request_all_duration_seconds',
295
- help: 'request duration in seconds summary for all requests',
296
- collect: function () {
297
- process.nextTick(() => this.reset())
298
- }
299
- },
300
- summary: {
301
- name: 'http_request_all_summary_seconds',
302
- help: 'request duration in seconds histogram for all requests',
303
- collect: function () {
304
- process.nextTick(() => this.reset())
305
- }
306
- }
307
- })
412
+ if (metricsConfig.httpMetrics) {
413
+ collectHttpServerMetrics(registry, metricsConfig)
414
+ if (isHttpClientMetricsEnabled(metricsConfig)) {
415
+ collectHttpClientMetrics(registry)
416
+ }
308
417
  }
309
418
 
310
419
  return {
@@ -365,28 +474,11 @@ export async function collectMetrics (applicationId, workerId, metricsConfig = {
365
474
  await collectThreadCpuMetrics(registry)
366
475
  }
367
476
 
368
- if (metricsConfig.httpMetrics && !ensureMetricsGroup(registry, 'http')) {
369
- // Build custom labels configuration
370
- const { customLabels, getCustomLabels } = buildCustomLabelsConfig(metricsConfig.httpCustomLabels)
371
-
372
- collectHttpMetrics(registry, {
373
- customLabels,
374
- getCustomLabels,
375
- histogram: {
376
- name: 'http_request_all_duration_seconds',
377
- help: 'request duration in seconds summary for all requests',
378
- collect: function () {
379
- process.nextTick(() => this.reset())
380
- }
381
- },
382
- summary: {
383
- name: 'http_request_all_summary_seconds',
384
- help: 'request duration in seconds histogram for all requests',
385
- collect: function () {
386
- process.nextTick(() => this.reset())
387
- }
388
- }
389
- })
477
+ if (metricsConfig.httpMetrics) {
478
+ collectHttpServerMetrics(registry, metricsConfig)
479
+ if (isHttpClientMetricsEnabled(metricsConfig)) {
480
+ collectHttpClientMetrics(registry)
481
+ }
390
482
  }
391
483
 
392
484
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platformatic/metrics",
3
- "version": "3.54.0",
3
+ "version": "3.56.0",
4
4
  "description": "Platformatic Capability Metrics",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/test/helper.js CHANGED
@@ -123,7 +123,8 @@ export const expectedMetrics = [
123
123
  },
124
124
  {
125
125
  name: 'http_request_all_duration_seconds',
126
- type: 'histogram'
126
+ type: 'histogram',
127
+ buckets: ['0.005', '0.01', '0.025', '0.05', '0.1', '0.25', '0.5', '1', '2.5', '5', '10']
127
128
  }
128
129
  ]
129
130
 
@@ -141,19 +142,24 @@ function assertSummary (metrics, metric) {
141
142
  }
142
143
 
143
144
  function assertHistogram (metrics, metric) {
144
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="0.005"'))
145
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="0.01"'))
146
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="0.025"'))
147
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="0.05"'))
148
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="0.1"'))
149
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="0.25"'))
150
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="0.5"'))
151
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="1"'))
152
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="2.5"'))
153
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="5"'))
154
- ok(metrics.includes('http_request_all_duration_seconds_bucket{le="10"'))
155
- ok(metrics.includes('http_request_all_duration_seconds_sum{'))
156
- ok(metrics.includes('http_request_all_duration_seconds_count{'))
145
+ const lines = metrics.split('\n')
146
+ const bucketLines = lines.filter(line => line.startsWith(`${metric.name}_bucket{`))
147
+
148
+ if (!metric.buckets) {
149
+ return
150
+ }
151
+
152
+ ok(bucketLines.length > 0, `Metric ${metric.name} buckets not found`)
153
+
154
+ for (const bucket of metric.buckets ?? []) {
155
+ ok(
156
+ bucketLines.some(line => line.includes(`le="${bucket}"`)),
157
+ `Metric ${metric.name} bucket ${bucket} not found`
158
+ )
159
+ }
160
+
161
+ ok(lines.some(line => line.startsWith(`${metric.name}_sum{`)), `Metric ${metric.name} sum not found`)
162
+ ok(lines.some(line => line.startsWith(`${metric.name}_count{`)), `Metric ${metric.name} count not found`)
157
163
  }
158
164
 
159
165
  export function assertMetric (metrics, metric) {
@@ -1,6 +1,7 @@
1
1
  import assert from 'node:assert'
2
+ import { channel } from 'node:diagnostics_channel'
2
3
  import { test } from 'node:test'
3
- import { buildCustomLabelsConfig, client, collectMetrics } from '../index.js'
4
+ import { buildCustomLabelsConfig, clearRegistry, client, collectMetrics } from '../index.js'
4
5
 
5
6
  const nextTick = () => new Promise(resolve => process.nextTick(resolve))
6
7
 
@@ -58,21 +59,88 @@ test('workerId is NOT included in labels when negative', async () => {
58
59
  assert.strictEqual(values[0].labels.workerId, undefined)
59
60
  })
60
61
 
61
- test('httpMetrics creates histogram and summary with collect functions', async () => {
62
+ test('httpMetrics creates histogram and summary with collect functions', async t => {
62
63
  const result = await collectMetrics('test-service', 1, { httpMetrics: true })
64
+ t.after(() => clearRegistry(result.registry))
63
65
  const metrics = await result.registry.getMetricsAsJSON()
64
66
 
65
67
  const histogram = metrics.find(m => m.name === 'http_request_all_duration_seconds')
66
68
  const summary = metrics.find(m => m.name === 'http_request_all_summary_seconds')
69
+ const clientHistogram = metrics.find(m => m.name === 'http_client_request_duration_seconds')
67
70
 
68
71
  assert.ok(histogram, 'histogram metric should exist')
69
72
  assert.ok(summary, 'summary metric should exist')
73
+ assert.strictEqual(clientHistogram, undefined, 'client histogram metric should not exist by default')
70
74
  assert.strictEqual(histogram.help, 'request duration in seconds summary for all requests')
71
75
  assert.strictEqual(summary.help, 'request duration in seconds histogram for all requests')
72
76
  })
73
77
 
74
- test('httpMetrics histogram resets after metric collection', async () => {
78
+ test('httpMetrics creates HTTP client histogram when enabled', async t => {
79
+ const result = await collectMetrics('test-service', 1, { httpMetrics: true, httpClientMetrics: true })
80
+ t.after(() => clearRegistry(result.registry))
81
+ const metrics = await result.registry.getMetricsAsJSON()
82
+
83
+ const clientHistogram = metrics.find(m => m.name === 'http_client_request_duration_seconds')
84
+
85
+ assert.ok(clientHistogram, 'client histogram metric should exist')
86
+ assert.strictEqual(clientHistogram.help, 'outgoing HTTP client request duration in seconds')
87
+ })
88
+
89
+ test('httpMetrics observes outgoing HTTP client request durations', async t => {
90
+ const result = await collectMetrics('test-service', 2, { httpMetrics: true, httpClientMetrics: true })
91
+ t.after(() => clearRegistry(result.registry))
92
+
93
+ const request = {
94
+ method: 'POST',
95
+ origin: 'https://api.example.com:8443',
96
+ path: '/v1/resources'
97
+ }
98
+
99
+ channel('undici:request:create').publish({ request })
100
+ channel('undici:request:headers').publish({ request, response: { statusCode: 201 } })
101
+ channel('undici:request:trailers').publish({ request })
102
+
103
+ const metrics = await result.registry.getMetricsAsJSON()
104
+ const histogram = metrics.find(m => m.name === 'http_client_request_duration_seconds')
105
+ const count = histogram.values.find(v => v.metricName === 'http_client_request_duration_seconds_count')
106
+
107
+ assert.strictEqual(count.value, 1)
108
+ assert.strictEqual(count.labels.applicationId, 'test-service')
109
+ assert.strictEqual(count.labels.workerId, 2)
110
+ assert.strictEqual(count.labels.method, 'POST')
111
+ assert.strictEqual(count.labels.status_code, 201)
112
+ assert.strictEqual(count.labels.dispatcher_stats_url, 'https://api.example.com:8443')
113
+ assert.strictEqual(count.labels.error_type, '')
114
+ })
115
+
116
+ test('httpMetrics observes outgoing HTTP client request errors', async t => {
117
+ const result = await collectMetrics('test-service', 3, { httpMetrics: true, httpClientMetrics: true })
118
+ t.after(() => clearRegistry(result.registry))
119
+
120
+ const request = {
121
+ method: 'GET',
122
+ origin: 'http://dependency.internal',
123
+ path: '/health'
124
+ }
125
+ const error = Object.assign(new Error('socket closed'), { code: 'UND_ERR_SOCKET' })
126
+
127
+ channel('undici:request:create').publish({ request })
128
+ channel('undici:request:error').publish({ request, error })
129
+
130
+ const metrics = await result.registry.getMetricsAsJSON()
131
+ const histogram = metrics.find(m => m.name === 'http_client_request_duration_seconds')
132
+ const count = histogram.values.find(v => v.metricName === 'http_client_request_duration_seconds_count')
133
+
134
+ assert.strictEqual(count.value, 1)
135
+ assert.strictEqual(count.labels.method, 'GET')
136
+ assert.strictEqual(count.labels.status_code, '')
137
+ assert.strictEqual(count.labels.dispatcher_stats_url, 'http://dependency.internal')
138
+ assert.strictEqual(count.labels.error_type, 'UND_ERR_SOCKET')
139
+ })
140
+
141
+ test('httpMetrics histogram resets after metric collection', async t => {
75
142
  const result = await collectMetrics('test-service', 1, { httpMetrics: true })
143
+ t.after(() => clearRegistry(result.registry))
76
144
 
77
145
  // Get the histogram metric using the public API
78
146
  const histogramMetric = result.registry.getSingleMetric('http_request_all_duration_seconds')
@@ -99,8 +167,9 @@ test('httpMetrics histogram resets after metric collection', async () => {
99
167
  assert.strictEqual(count?.value || 0, 0, 'histogram count should be reset to 0')
100
168
  })
101
169
 
102
- test('httpMetrics summary resets after metric collection', async () => {
170
+ test('httpMetrics summary resets after metric collection', async t => {
103
171
  const result = await collectMetrics('test-service', 1, { httpMetrics: true })
172
+ t.after(() => clearRegistry(result.registry))
104
173
 
105
174
  // Get the summary metric using the public API
106
175
  const summaryMetric = result.registry.getSingleMetric('http_request_all_summary_seconds')
@@ -203,7 +272,7 @@ test('buildCustomLabelsConfig handles case-insensitive header names', () => {
203
272
  assert.deepStrictEqual(labels, { domain: 'example.com' })
204
273
  })
205
274
 
206
- test('httpMetrics with custom labels configuration', async () => {
275
+ test('httpMetrics with custom labels configuration', async t => {
207
276
  const httpCustomLabels = [
208
277
  { name: 'domain', header: 'x-forwarded-host', default: 'localhost' }
209
278
  ]
@@ -212,6 +281,7 @@ test('httpMetrics with custom labels configuration', async () => {
212
281
  httpMetrics: true,
213
282
  httpCustomLabels
214
283
  })
284
+ t.after(() => clearRegistry(result.registry))
215
285
 
216
286
  const metrics = await result.registry.getMetricsAsJSON()
217
287
  const histogram = metrics.find(m => m.name === 'http_request_all_duration_seconds')
@@ -230,8 +300,9 @@ test('httpMetrics with custom labels configuration', async () => {
230
300
  assert.ok(hasCustomLabel, 'custom domain label should be present in histogram values')
231
301
  })
232
302
 
233
- test('httpMetrics does not include telemetry_id label by default', async () => {
303
+ test('httpMetrics does not include telemetry_id label by default', async t => {
234
304
  const result = await collectMetrics('test-service', 1, { httpMetrics: true })
305
+ t.after(() => clearRegistry(result.registry))
235
306
 
236
307
  const histogramMetric = result.registry.getSingleMetric('http_request_all_duration_seconds')
237
308
  assert.ok(histogramMetric, 'histogram metric should exist')