diff --git a/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts b/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts index d9b4b0f05cd..a05e25abe5a 100644 --- a/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts +++ b/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts @@ -63,6 +63,27 @@ function sanitizePrometheusMetricName(name: string): string { return name.replace(invalidCharacterRegex, '_'); // replace all invalid characters with '_' } +/** + * @private + * + * Helper method which assists in enforcing the naming conventions for metric + * names in Prometheus + * @param name the name of the metric + * @param kind the kind of metric + * @returns string + */ +function enforcePrometheusNamingConvention( + name: string, + kind: MetricKind +): string { + // Prometheus requires that metrics of the Counter kind have "_total" suffix + if (!name.endsWith('_total') && kind === MetricKind.COUNTER) { + name = name + '_total'; + } + + return name; +} + function valueString(value: number) { if (Number.isNaN(value)) { return 'Nan'; @@ -162,6 +183,12 @@ export class PrometheusSerializer { if (this._prefix) { name = `${this._prefix}${name}`; } + + name = enforcePrometheusNamingConvention( + name, + checkpoint.descriptor.metricKind + ); + const help = `# HELP ${name} ${escapeString( checkpoint.descriptor.description || 'description missing' )}`; @@ -179,6 +206,12 @@ export class PrometheusSerializer { serializeRecord(name: string, record: MetricRecord): string { let results = ''; + + name = enforcePrometheusNamingConvention( + name, + record.descriptor.metricKind + ); + switch (record.aggregator.kind) { case AggregatorKind.SUM: case AggregatorKind.LAST_VALUE: { diff --git a/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts b/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts index 6954fd29548..29527736f13 100644 --- a/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts +++ b/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts @@ -229,7 +229,7 @@ describe('PrometheusExporter', () => { }); it('should export a count aggregation', done => { - const counter = meter.createCounter('counter', { + const counter = meter.createCounter('counter_total', { description: 'a test description', }); @@ -251,13 +251,13 @@ describe('PrometheusExporter', () => { assert.strictEqual( lines[0], - '# HELP counter a test description' + '# HELP counter_total a test description' ); assert.deepStrictEqual(lines, [ - '# HELP counter a test description', - '# TYPE counter counter', - `counter{key1="labelValue1"} 20 ${mockedHrTimeMs}`, + '# HELP counter_total a test description', + '# TYPE counter_total counter', + `counter_total{key1="labelValue1"} 20 ${mockedHrTimeMs}`, '', ]); @@ -313,7 +313,7 @@ describe('PrometheusExporter', () => { }); it('should export multiple labels', done => { - const counter = meter.createCounter('counter', { + const counter = meter.createCounter('counter_total', { description: 'a test description', }) as CounterMetric; @@ -328,10 +328,10 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ - '# HELP counter a test description', - '# TYPE counter counter', - `counter{counterKey1="labelValue1"} 10 ${mockedHrTimeMs}`, - `counter{counterKey1="labelValue2"} 20 ${mockedHrTimeMs}`, + '# HELP counter_total a test description', + '# TYPE counter_total counter', + `counter_total{counterKey1="labelValue1"} 10 ${mockedHrTimeMs}`, + `counter_total{counterKey1="labelValue2"} 20 ${mockedHrTimeMs}`, '', ]); @@ -344,7 +344,7 @@ describe('PrometheusExporter', () => { }); it('should export multiple labels on manual shutdown', done => { - const counter = meter.createCounter('counter', { + const counter = meter.createCounter('counter_total', { description: 'a test description', }) as CounterMetric; @@ -359,11 +359,11 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ - '# HELP counter a test description', - '# TYPE counter counter', - `counter{counterKey1="labelValue1"} 10 ${mockedHrTimeMs}`, - `counter{counterKey1="labelValue2"} 20 ${mockedHrTimeMs}`, - `counter{counterKey1="labelValue3"} 30 ${mockedHrTimeMs}`, + '# HELP counter_total a test description', + '# TYPE counter_total counter', + `counter_total{counterKey1="labelValue1"} 10 ${mockedHrTimeMs}`, + `counter_total{counterKey1="labelValue2"} 20 ${mockedHrTimeMs}`, + `counter_total{counterKey1="labelValue3"} 30 ${mockedHrTimeMs}`, '', ]); @@ -392,7 +392,7 @@ describe('PrometheusExporter', () => { }); it('should add a description if missing', done => { - const counter = meter.createCounter('counter'); + const counter = meter.createCounter('counter_total'); const boundCounter = counter.bind({ key1: 'labelValue1' }); boundCounter.add(10); @@ -405,9 +405,9 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ - '# HELP counter description missing', - '# TYPE counter counter', - `counter{key1="labelValue1"} 10 ${mockedHrTimeMs}`, + '# HELP counter_total description missing', + '# TYPE counter_total counter', + `counter_total{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -432,9 +432,9 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ - '# HELP counter_bad_name description missing', - '# TYPE counter_bad_name counter', - `counter_bad_name{key1="labelValue1"} 10 ${mockedHrTimeMs}`, + '# HELP counter_bad_name_total description missing', + '# TYPE counter_bad_name_total counter', + `counter_bad_name_total{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -620,9 +620,9 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ - '# HELP test_prefix_counter description missing', - '# TYPE test_prefix_counter counter', - `test_prefix_counter{key1="labelValue1"} 10 ${mockedHrTimeMs}`, + '# HELP test_prefix_counter_total description missing', + '# TYPE test_prefix_counter_total counter', + `test_prefix_counter_total{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -650,9 +650,9 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ - '# HELP counter description missing', - '# TYPE counter counter', - `counter{key1="labelValue1"} 10 ${mockedHrTimeMs}`, + '# HELP counter_total description missing', + '# TYPE counter_total counter', + `counter_total{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -680,9 +680,9 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ - '# HELP counter description missing', - '# TYPE counter counter', - `counter{key1="labelValue1"} 10 ${mockedHrTimeMs}`, + '# HELP counter_total description missing', + '# TYPE counter_total counter', + `counter_total{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -710,9 +710,9 @@ describe('PrometheusExporter', () => { const lines = body.split('\n'); assert.deepStrictEqual(lines, [ - '# HELP counter description missing', - '# TYPE counter counter', - 'counter{key1="labelValue1"} 10', + '# HELP counter_total description missing', + '# TYPE counter_total counter', + 'counter_total{key1="labelValue1"} 10', '', ]); diff --git a/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts b/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts index 7a05e52334c..07d240ff42d 100644 --- a/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts +++ b/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts @@ -23,6 +23,7 @@ import { UpDownCounterMetric, ValueObserverMetric, } from '@opentelemetry/metrics'; +import { diag, DiagLogLevel } from '@opentelemetry/api'; import * as assert from 'assert'; import { Labels } from '@opentelemetry/api-metrics'; import { PrometheusSerializer } from '../src/PrometheusSerializer'; @@ -53,7 +54,7 @@ describe('PrometheusSerializer', () => { const meter = new MeterProvider({ processor: new ExactProcessor(SumAggregator), }).getMeter('test'); - const counter = meter.createCounter('test') as CounterMetric; + const counter = meter.createCounter('test_total') as CounterMetric; counter.bind(labels).add(1); const records = await counter.getMetricRecord(); @@ -65,7 +66,7 @@ describe('PrometheusSerializer', () => { ); assert.strictEqual( result, - `test{foo1="bar1",foo2="bar2"} 1 ${mockedHrTimeMs}\n` + `test_total{foo1="bar1",foo2="bar2"} 1 ${mockedHrTimeMs}\n` ); }); @@ -75,7 +76,7 @@ describe('PrometheusSerializer', () => { const meter = new MeterProvider({ processor: new ExactProcessor(SumAggregator), }).getMeter('test'); - const counter = meter.createCounter('test') as CounterMetric; + const counter = meter.createCounter('test_total') as CounterMetric; counter.bind(labels).add(1); const records = await counter.getMetricRecord(); @@ -85,7 +86,7 @@ describe('PrometheusSerializer', () => { record.descriptor.name, record ); - assert.strictEqual(result, 'test{foo1="bar1",foo2="bar2"} 1\n'); + assert.strictEqual(result, 'test_total{foo1="bar1",foo2="bar2"} 1\n'); }); }); @@ -155,6 +156,7 @@ describe('PrometheusSerializer', () => { const recorder = meter.createValueRecorder('test', { description: 'foobar', }) as ValueRecorderMetric; + recorder.bind(labels).record(5); const records = await recorder.getMetricRecord(); @@ -244,7 +246,7 @@ describe('PrometheusSerializer', () => { processor: new ExactProcessor(SumAggregator), }).getMeter('test'); const processor = new PrometheusLabelsBatcher(); - const counter = meter.createCounter('test', { + const counter = meter.createCounter('test_total', { description: 'foobar', }) as CounterMetric; counter.bind({ val: '1' }).add(1); @@ -257,10 +259,10 @@ describe('PrometheusSerializer', () => { const result = serializer.serialize(checkPointSet); assert.strictEqual( result, - '# HELP test foobar\n' + - '# TYPE test counter\n' + - `test{val="1"} 1 ${mockedHrTimeMs}\n` + - `test{val="2"} 1 ${mockedHrTimeMs}\n` + '# HELP test_total foobar\n' + + '# TYPE test_total counter\n' + + `test_total{val="1"} 1 ${mockedHrTimeMs}\n` + + `test_total{val="2"} 1 ${mockedHrTimeMs}\n` ); }); @@ -271,7 +273,7 @@ describe('PrometheusSerializer', () => { processor: new ExactProcessor(SumAggregator), }).getMeter('test'); const processor = new PrometheusLabelsBatcher(); - const counter = meter.createCounter('test', { + const counter = meter.createCounter('test_total', { description: 'foobar', }) as CounterMetric; counter.bind({ val: '1' }).add(1); @@ -284,10 +286,10 @@ describe('PrometheusSerializer', () => { const result = serializer.serialize(checkPointSet); assert.strictEqual( result, - '# HELP test foobar\n' + - '# TYPE test counter\n' + - 'test{val="1"} 1\n' + - 'test{val="2"} 1\n' + '# HELP test_total foobar\n' + + '# TYPE test_total counter\n' + + 'test_total{val="1"} 1\n' + + 'test_total{val="2"} 1\n' ); }); }); @@ -370,6 +372,54 @@ describe('PrometheusSerializer', () => { }); }); + describe('validate against metric conventions', () => { + mockAggregator(SumAggregator); + + it('should rename metric of type counter when name misses _total suffix', async () => { + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + processor: new ExactProcessor(SumAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test') as CounterMetric; + counter.bind({}).add(1); + + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord(record.descriptor.name, record); + assert.strictEqual(result, `test_total 1 ${mockedHrTimeMs}\n`); + }); + + it('should not warn for counter metrics with correct name', async () => { + let calledArgs: any[] = []; + const dummyLogger = { + verbose: () => {}, + debug: (...args: any[]) => { + calledArgs = args; + }, + info: () => {}, + warn: () => {}, + error: () => {}, + }; + diag.setLogger(dummyLogger, DiagLogLevel.ALL); + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + processor: new ExactProcessor(SumAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test_total') as CounterMetric; + counter.bind({}).add(1); + + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord(record.descriptor.name, record); + assert.strictEqual(result, `test_total 1 ${mockedHrTimeMs}\n`); + assert.ok(calledArgs.length === 0); + }); + }); + describe('serialize non-normalized values', () => { describe('with SumAggregator', () => { mockAggregator(SumAggregator); @@ -380,7 +430,7 @@ describe('PrometheusSerializer', () => { const meter = new MeterProvider({ processor: new ExactProcessor(SumAggregator), }).getMeter('test'); - const counter = meter.createCounter('test') as CounterMetric; + const counter = meter.createCounter('test_total') as CounterMetric; counter.bind({}).add(1); const records = await counter.getMetricRecord(); @@ -390,7 +440,7 @@ describe('PrometheusSerializer', () => { record.descriptor.name, record ); - assert.strictEqual(result, `test 1 ${mockedHrTimeMs}\n`); + assert.strictEqual(result, `test_total 1 ${mockedHrTimeMs}\n`); }); it('should serialize non-string label values', async () => { @@ -399,7 +449,7 @@ describe('PrometheusSerializer', () => { const meter = new MeterProvider({ processor: new ExactProcessor(SumAggregator), }).getMeter('test'); - const counter = meter.createCounter('test') as CounterMetric; + const counter = meter.createCounter('test_total') as CounterMetric; counter .bind(({ object: {}, @@ -417,7 +467,7 @@ describe('PrometheusSerializer', () => { ); assert.strictEqual( result, - `test{object="[object Object]",NaN="NaN",null="null",undefined="undefined"} 1 ${mockedHrTimeMs}\n` + `test_total{object="[object Object]",NaN="NaN",null="null",undefined="undefined"} 1 ${mockedHrTimeMs}\n` ); }); @@ -457,7 +507,7 @@ describe('PrometheusSerializer', () => { const meter = new MeterProvider({ processor: new ExactProcessor(SumAggregator), }).getMeter('test'); - const counter = meter.createCounter('test') as CounterMetric; + const counter = meter.createCounter('test_total') as CounterMetric; counter .bind(({ backslash: '\u005c', // \ => \\ (\u005c\u005c) @@ -477,7 +527,7 @@ describe('PrometheusSerializer', () => { ); assert.strictEqual( result, - 'test{' + + 'test_total{' + 'backslash="\u005c\u005c",' + 'doubleQuote="\u005c\u0022",' + 'lineFeed="\u005c\u006e",' + @@ -493,7 +543,7 @@ describe('PrometheusSerializer', () => { const meter = new MeterProvider({ processor: new ExactProcessor(SumAggregator), - }).getMeter('test'); + }).getMeter('test_total'); const counter = meter.createCounter('test') as CounterMetric; // if you try to use a label name like account-id prometheus will complain // with an error like: @@ -512,7 +562,7 @@ describe('PrometheusSerializer', () => { ); assert.strictEqual( result, - `test{account_id="123456"} 1 ${mockedHrTimeMs}\n` + `test_total{account_id="123456"} 1 ${mockedHrTimeMs}\n` ); }); });