Skip to content

Commit 516ed95

Browse files
committed
fix: provide unique metric identity for workers
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 8e2704e commit 516ed95

3 files changed

Lines changed: 191 additions & 4 deletions

File tree

monitoring/otel/config/otel-collector-config.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,18 @@ processors:
1515
batch/metrics:
1616
send_batch_size: 10000
1717
timeout: 10s
18-
# Copy region and instance from Resource attributes to metric data point attributes
19-
# This ensures all metrics have the region and instance labels
18+
# Copy Resource attributes to metric data point attributes.
19+
# This ensures all metrics have stable routing labels and a unique writer label.
2020
transform/add_resource_attributes:
2121
metric_statements:
2222
- context: datapoint
2323
statements:
2424
- set(attributes["region"], resource.attributes["region"]) where resource.attributes["region"] != nil
2525
- set(attributes["instance"], resource.attributes["instance"]) where resource.attributes["instance"] != nil
26+
- set(attributes["service.name"], resource.attributes["service.name"]) where resource.attributes["service.name"] != nil
27+
- set(attributes["service_instance_id"], resource.attributes["service.instance.id"]) where resource.attributes["service.instance.id"] != nil
28+
- set(attributes["worker_id"], resource.attributes["worker.id"]) where resource.attributes["worker.id"] != nil
29+
- set(attributes["platformatic_application_id"], resource.attributes["platformatic.application.id"]) where resource.attributes["platformatic.application.id"] != nil
2630
# Add storage_api_otel_ prefix to all metrics
2731
# Note: storage_api_ transform must be FIRST to avoid double prefix
2832
metricstransform/host:

src/internal/monitoring/otel-metrics.test.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ interface OTelGlobalState {
22
__otelMetricsShutdown?: () => Promise<void>
33
}
44

5+
import fs from 'node:fs'
56
import { vi } from 'vitest'
67

78
const mockedMetricsModules = [
@@ -16,6 +17,8 @@ const mockedMetricsModules = [
1617
'@opentelemetry/instrumentation-runtime-node',
1718
'@opentelemetry/resources',
1819
'@opentelemetry/sdk-metrics',
20+
'@platformatic/globals',
21+
'os',
1922
] as const
2023

2124
async function importOtelMetricsModule() {
@@ -262,4 +265,142 @@ describe('otel metrics', () => {
262265
})
263266
)
264267
})
268+
269+
test('uses Watt worker identity as the OTel service instance id', async () => {
270+
delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT
271+
delete process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT
272+
273+
const registerInstrumentations = vi.fn(() => vi.fn())
274+
const resourceFromAttributes = vi.fn((attributes) => attributes)
275+
const HostMetrics = vi.fn(function () {
276+
return {
277+
start: vi.fn(),
278+
}
279+
})
280+
const MeterProvider = vi.fn(function () {
281+
return {
282+
shutdown: vi.fn().mockResolvedValue(undefined),
283+
getMeter: vi.fn(() => ({})),
284+
}
285+
})
286+
const prometheusExporterOptions: Array<{ withResourceConstantLabels: RegExp }> = []
287+
const PrometheusExporter = vi.fn(function (options: { withResourceConstantLabels: RegExp }) {
288+
prometheusExporterOptions.push(options)
289+
return {
290+
getMetricsRequestHandler: vi.fn(),
291+
}
292+
})
293+
const RuntimeNodeInstrumentation = vi.fn(function () {
294+
return {}
295+
})
296+
const StorageNodeInstrumentation = vi.fn(function () {
297+
return {}
298+
})
299+
300+
vi.doMock('../../config', () => ({
301+
getConfig: vi.fn(() => ({
302+
version: 'test-version',
303+
otelMetricsExportIntervalMs: 1000,
304+
otelMetricsEnabled: true,
305+
otelMetricsTemporality: 'CUMULATIVE',
306+
prometheusMetricsEnabled: true,
307+
region: 'local',
308+
serviceName: 'storage-api',
309+
})),
310+
}))
311+
vi.doMock('@platformatic/globals', () => ({
312+
getGlobal: vi.fn(() => ({
313+
applicationId: 'storage-api:tenant',
314+
workerId: '3',
315+
})),
316+
}))
317+
vi.doMock('os', () => ({
318+
hostname: vi.fn(() => 'storage-host-a'),
319+
}))
320+
vi.doMock('@internal/monitoring/logger', () => ({
321+
logger: { info: vi.fn() },
322+
logSchema: { error: vi.fn(), info: vi.fn() },
323+
}))
324+
vi.doMock('@internal/monitoring/system', () => ({
325+
StorageNodeInstrumentation,
326+
}))
327+
vi.doMock('@opentelemetry/api', () => ({
328+
metrics: {
329+
setGlobalMeterProvider: vi.fn(),
330+
},
331+
}))
332+
vi.doMock('@opentelemetry/exporter-metrics-otlp-grpc', () => ({
333+
OTLPMetricExporter: vi.fn(function () {
334+
return {}
335+
}),
336+
}))
337+
vi.doMock('@opentelemetry/exporter-prometheus', () => ({
338+
PrometheusExporter,
339+
}))
340+
vi.doMock('@opentelemetry/host-metrics', () => ({
341+
HostMetrics,
342+
}))
343+
vi.doMock('@opentelemetry/instrumentation', () => ({
344+
registerInstrumentations,
345+
}))
346+
vi.doMock('@opentelemetry/instrumentation-runtime-node', () => ({
347+
RuntimeNodeInstrumentation,
348+
}))
349+
vi.doMock('@opentelemetry/resources', () => ({
350+
resourceFromAttributes,
351+
}))
352+
vi.doMock('@opentelemetry/sdk-metrics', () => ({
353+
AggregationTemporality: {
354+
CUMULATIVE: 'CUMULATIVE',
355+
DELTA: 'DELTA',
356+
},
357+
AggregationType: {
358+
DROP: 'DROP',
359+
EXPLICIT_BUCKET_HISTOGRAM: 'EXPLICIT_BUCKET_HISTOGRAM',
360+
},
361+
MeterProvider,
362+
PeriodicExportingMetricReader: vi.fn(),
363+
}))
364+
365+
await importOtelMetricsModule()
366+
367+
expect(resourceFromAttributes).toHaveBeenCalledWith(
368+
expect.objectContaining({
369+
instance: 'storage-host-a',
370+
'service.instance.id': 'storage-host-a:storage-api:tenant:worker:3',
371+
'platformatic.application.id': 'storage-api:tenant',
372+
'worker.id': '3',
373+
'process.pid': process.pid,
374+
})
375+
)
376+
expect(PrometheusExporter).toHaveBeenCalledWith(
377+
expect.objectContaining({
378+
withResourceConstantLabels: expect.objectContaining({
379+
test: expect.any(Function),
380+
}),
381+
})
382+
)
383+
384+
const prometheusLabelFilter = prometheusExporterOptions[0].withResourceConstantLabels
385+
expect(prometheusLabelFilter.test('service.instance.id')).toBe(true)
386+
expect(prometheusLabelFilter.test('worker.id')).toBe(true)
387+
expect(prometheusLabelFilter.test('platformatic.application.id')).toBe(true)
388+
})
389+
390+
test('collector promotes service instance identity to metric labels', () => {
391+
const collectorConfig = fs.readFileSync(
392+
'monitoring/otel/config/otel-collector-config.yml',
393+
'utf8'
394+
)
395+
396+
expect(collectorConfig).toContain(
397+
'set(attributes["service_instance_id"], resource.attributes["service.instance.id"])'
398+
)
399+
expect(collectorConfig).toContain(
400+
'set(attributes["worker_id"], resource.attributes["worker.id"])'
401+
)
402+
expect(collectorConfig).toContain(
403+
'set(attributes["platformatic_application_id"], resource.attributes["platformatic.application.id"])'
404+
)
405+
})
265406
})

src/internal/monitoring/otel-metrics.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
PeriodicExportingMetricReader,
1717
} from '@opentelemetry/sdk-metrics'
1818
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'
19+
import { getGlobal } from '@platformatic/globals'
1920
import { FastifyReply, FastifyRequest } from 'fastify'
2021
import * as os from 'os'
2122
import { getConfig } from '../../config'
@@ -39,6 +40,39 @@ interface OTelMetricsGlobalState {
3940
__otelMetricsShutdown?: () => Promise<void>
4041
}
4142

43+
const SERVICE_INSTANCE_ID_ATTRIBUTE = 'service.instance.id'
44+
const PROCESS_PID_ATTRIBUTE = 'process.pid'
45+
const WORKER_ID_ATTRIBUTE = 'worker.id'
46+
const PLATFORMATIC_APPLICATION_ID_ATTRIBUTE = 'platformatic.application.id'
47+
48+
function normalizeMetricIdentityPart(value: unknown): string | undefined {
49+
if (typeof value === 'number' && Number.isFinite(value)) {
50+
return `${value}`
51+
}
52+
53+
if (typeof value === 'string') {
54+
const trimmed = value.trim()
55+
return trimmed === '' ? undefined : trimmed
56+
}
57+
58+
return undefined
59+
}
60+
61+
function resolveMetricIdentity() {
62+
const hostname = os.hostname()
63+
const platformatic = getGlobal()
64+
const applicationId = normalizeMetricIdentityPart(platformatic?.applicationId)
65+
const workerId = normalizeMetricIdentityPart(platformatic?.workerId)
66+
const runtimeId = workerId === undefined ? `pid:${process.pid}` : `worker:${workerId}`
67+
68+
return {
69+
instance: hostname,
70+
serviceInstanceId: [hostname, applicationId, runtimeId].filter(Boolean).join(':'),
71+
applicationId,
72+
workerId,
73+
}
74+
}
75+
4276
function unregisterMetricInstrumentation(unregister: (() => void) | undefined) {
4377
if (!unregister) {
4478
return
@@ -57,7 +91,8 @@ function unregisterMetricInstrumentation(unregister: (() => void) | undefined) {
5791
// =============================================================================
5892
// Shared config
5993
// =============================================================================
60-
const instance = os.hostname()
94+
const metricIdentity = resolveMetricIdentity()
95+
const instance = metricIdentity.instance
6196
const headersEnv = process.env.OTEL_EXPORTER_OTLP_METRICS_HEADERS || ''
6297
const otlpEndpoint =
6398
process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT || process.env.OTEL_EXPORTER_OTLP_ENDPOINT
@@ -85,6 +120,12 @@ const resource = resourceFromAttributes({
85120
'metric.version': '1',
86121
region,
87122
instance,
123+
[SERVICE_INSTANCE_ID_ATTRIBUTE]: metricIdentity.serviceInstanceId,
124+
[PROCESS_PID_ATTRIBUTE]: process.pid,
125+
...(metricIdentity.workerId ? { [WORKER_ID_ATTRIBUTE]: metricIdentity.workerId } : {}),
126+
...(metricIdentity.applicationId
127+
? { [PLATFORMATIC_APPLICATION_ID_ATTRIBUTE]: metricIdentity.applicationId }
128+
: {}),
88129
})
89130

90131
// Bucket boundaries for duration histograms (in seconds)
@@ -256,7 +297,8 @@ if (otelMetricsEnabled) {
256297
prometheusExporter = new PrometheusExporter({
257298
prefix: serviceName,
258299
preventServerStart: true,
259-
withResourceConstantLabels: /^(region|instance|metric\.version|service\.name)$/,
300+
withResourceConstantLabels:
301+
/^(region|instance|metric\.version|service\.name|service\.instance\.id|worker\.id|platformatic\.application\.id)$/,
260302
})
261303
readers.push(prometheusExporter)
262304
}

0 commit comments

Comments
 (0)