diff --git a/CHANGELOG.md b/CHANGELOG.md index 602914230b3..254e811b45d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ## Unreleased +* feat(sdk-metrics): adds the cardinalitySelector argument to PeriodicExportingMetricReaders + [#6460](https://github.com/open-telemetry/opentelemetry-js/pull/6460) @starzlocker + ### :boom: Breaking Changes ### :rocket: Features diff --git a/packages/sdk-metrics/README.md b/packages/sdk-metrics/README.md index 035a1de01b2..c2c1e04d1c1 100644 --- a/packages/sdk-metrics/README.md +++ b/packages/sdk-metrics/README.md @@ -33,6 +33,8 @@ opentelemetry.metrics.setGlobalMeterProvider(new MeterProvider()); const counter = opentelemetry.metrics.getMeter('default').createCounter('foo'); // record a metric event. +// NOTE: By default, each instrument can track up to 2000 unique time series. +// This can be configured using cardinalityLimits. See "Configuring Cardinality Limits" below. counter.add(1, { attributeKey: 'attribute-value' }); ``` @@ -40,21 +42,25 @@ In conditions, we may need to setup an async instrument to observe costly events ```js // Creating an async instrument, similar to synchronous instruments -const observableCounter = opentelemetry.metrics.getMeter('default') +const observableCounter = opentelemetry.metrics + .getMeter('default') .createObservableCounter('observable-counter'); // Register a single-instrument callback to the async instrument. -observableCounter.addCallback(async (observableResult) => { +observableCounter.addCallback(async observableResult => { // ... do async stuff observableResult.observe(1, { attributeKey: 'attribute-value' }); }); // Register a multi-instrument callback and associate it with a set of async instruments. -opentelemetry.metrics.getMeter('default') - .addBatchObservableCallback(batchObservableCallback, [ observableCounter ]); +opentelemetry.metrics + .getMeter('default') + .addBatchObservableCallback(batchObservableCallback, [observableCounter]); async function batchObservableCallback(batchObservableResult) { // ... do async stuff - batchObservableResult.observe(observableCounter, 1, { attributeKey: 'attribute-value' }); + batchObservableResult.observe(observableCounter, 1, { + attributeKey: 'attribute-value', + }); } ``` @@ -68,15 +74,71 @@ const meterProvider = new MeterProvider({ aggregation: { type: AggregationType.EXPLICIT_BUCKET_HISTOGRAM, options: { - boundaries: [0, 50, 100] - } + boundaries: [0, 50, 100], + }, }, - instrumentName: 'my.histogram' + instrumentName: 'my.histogram', }, // rename 'my.counter' to 'my.renamed.counter' - { name: 'my.renamed.counter', instrumentName: 'my.counter'} - ] -}) + { name: 'my.renamed.counter', instrumentName: 'my.counter' }, + ], +}); +``` + +## Configuring Cardinality Limits + +The `cardinalityLimits` is an optional property in `PeriodicExportingMetricReader` that allows configuration of the maximum cardinality limits per instrument type (`InstrumentType`). This limit controls the maximum number of unique time series that can be tracked for each metric instrument. If not specified in the property, the limit will default to 2000 (the default value can also be specified). + +It is converted to a `cardinalitySelector` function that: + +- Takes an `InstrumentType` as input +- Returns the configured cardinality limit for that instrument type +- Falls back to the default value if a specific type isn't configured +- Uses 2000 as the default if the default value is also not specified + +If the `cardinalityLimits` property is omitted: + +```js +import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; +import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-http'; + +const exporter = new OTLPMetricExporter(); +const reader = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 60000, +}); + +// All instruments will use the default limit of 2000 time series +``` + +Configuring specific instrument types: + +```js +const reader = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 60000, + cardinalityLimits: { + counter: 10000, // Counters can have up to 10,000 time series + histogram: 5000, // Histograms limited to 5,000 time series + gauge: 3000, // Gauges limited to 3,000 time series + default: 2500, // changes the default from 2000 to 2500 + }, +}); +``` + +Available configuration options: + +```js +type cardinalityLimits = { + counter?: number; + gauge?: number; + histogram?: number; + upDownCounter?: number; + observableCounter?: number; + observableGauge?: number; + observableUpDownCounter?: number; + default?: number; +}; ``` ## Example diff --git a/packages/sdk-metrics/src/export/PeriodicExportingMetricReader.ts b/packages/sdk-metrics/src/export/PeriodicExportingMetricReader.ts index 9d604695250..df489066883 100644 --- a/packages/sdk-metrics/src/export/PeriodicExportingMetricReader.ts +++ b/packages/sdk-metrics/src/export/PeriodicExportingMetricReader.ts @@ -13,6 +13,7 @@ import { MetricReader } from './MetricReader'; import type { PushMetricExporter } from './MetricExporter'; import { callWithTimeout, TimeoutError } from '../utils'; import type { MetricProducer } from './MetricProducer'; +import { InstrumentType } from './MetricData'; export type PeriodicExportingMetricReaderOptions = { /** @@ -35,6 +36,20 @@ export type PeriodicExportingMetricReaderOptions = { * @experimental */ metricProducers?: MetricProducer[]; + /** + * Cardinality limits for the metric reader, applied per instrument. If not configured, defaults to 2000 time series per instrument. These are wrapped in a cardinalitySelector function that returns limits based on the instrument type, so they can be configured differently per type if desired. + * + */ + cardinalityLimits?: { + counter?: number; + gauge?: number; + histogram?: number; + upDownCounter?: number; + observableCounter?: number; + observableGauge?: number; + observableUpDownCounter?: number; + default?: number; + }; }; /** @@ -48,7 +63,12 @@ export class PeriodicExportingMetricReader extends MetricReader { private readonly _exportTimeout: number; constructor(options: PeriodicExportingMetricReaderOptions) { - const { exporter, exportIntervalMillis = 60000, metricProducers } = options; + const { + exporter, + exportIntervalMillis = 60000, + metricProducers, + cardinalityLimits, + } = options; let { exportTimeoutMillis = 30000 } = options; super({ @@ -56,6 +76,31 @@ export class PeriodicExportingMetricReader extends MetricReader { aggregationTemporalitySelector: exporter.selectAggregationTemporality?.bind(exporter), metricProducers, + cardinalitySelector: (instrumentType: InstrumentType) => { + const limits = { + default: 2000, + ...cardinalityLimits, + }; + + switch (instrumentType) { + case InstrumentType.COUNTER: + return limits.counter ?? limits.default; + case InstrumentType.GAUGE: + return limits.gauge ?? limits.default; + case InstrumentType.HISTOGRAM: + return limits.histogram ?? limits.default; + case InstrumentType.OBSERVABLE_COUNTER: + return limits.observableCounter ?? limits.default; + case InstrumentType.OBSERVABLE_UP_DOWN_COUNTER: + return limits.observableUpDownCounter ?? limits.default; + case InstrumentType.OBSERVABLE_GAUGE: + return limits.observableGauge ?? limits.default; + case InstrumentType.UP_DOWN_COUNTER: + return limits.upDownCounter ?? limits.default; + default: + return limits.default; + } + }, }); if (exportIntervalMillis <= 0) { diff --git a/packages/sdk-metrics/test/export/PeriodicExportingMetricReader.test.ts b/packages/sdk-metrics/test/export/PeriodicExportingMetricReader.test.ts index ae02ef56878..35e19b0ece1 100644 --- a/packages/sdk-metrics/test/export/PeriodicExportingMetricReader.test.ts +++ b/packages/sdk-metrics/test/export/PeriodicExportingMetricReader.test.ts @@ -8,10 +8,10 @@ import { AggregationTemporality } from '../../src/export/AggregationTemporality' import type { AggregationOption, CollectionResult, - InstrumentType, MetricProducer, PushMetricExporter, } from '../../src'; +import { InstrumentType } from '../../src/export/MetricData'; import { AggregationType } from '../../src'; import type { ResourceMetrics, @@ -250,6 +250,140 @@ describe('PeriodicExportingMetricReader', () => { }); }); + describe('cardinalityLimits', () => { + const instrumentTypes = [ + { instrument: InstrumentType.COUNTER, name: 'counter' }, + { instrument: InstrumentType.GAUGE, name: 'gauge' }, + { instrument: InstrumentType.HISTOGRAM, name: 'histogram' }, + { instrument: InstrumentType.UP_DOWN_COUNTER, name: 'upDownCounter' }, + { + instrument: InstrumentType.OBSERVABLE_COUNTER, + name: 'observableCounter', + }, + { instrument: InstrumentType.OBSERVABLE_GAUGE, name: 'observableGauge' }, + { + instrument: InstrumentType.OBSERVABLE_UP_DOWN_COUNTER, + name: 'observableUpDownCounter', + }, + ] as const; + + it('should use default cardinality limit value if not specified', () => { + const exporter = new TestDeltaMetricExporter(); + + const p = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 1, + exportTimeoutMillis: 1, + }); + + assert.strictEqual( + p.selectCardinalityLimit(InstrumentType.COUNTER), + 2000 + ); + }); + + instrumentTypes.forEach(({ instrument, name }) => { + it('should use the provided cardinality limit for ' + name, () => { + const exporter = new TestDeltaMetricExporter(); + + const p = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 1, + exportTimeoutMillis: 1, + cardinalityLimits: { + [name]: 5000, + }, + }); + assert.strictEqual(p.selectCardinalityLimit(instrument), 5000); + }); + }); + + it('should use the provided default cardinality limit when no specific limit is provided', () => { + const exporter = new TestDeltaMetricExporter(); + + const defaultLimit = 1; + + const instrumentTypesValues = [ + InstrumentType.COUNTER, + InstrumentType.GAUGE, + InstrumentType.HISTOGRAM, + InstrumentType.UP_DOWN_COUNTER, + InstrumentType.OBSERVABLE_COUNTER, + InstrumentType.OBSERVABLE_GAUGE, + InstrumentType.OBSERVABLE_UP_DOWN_COUNTER, + ]; + + const p = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 1, + exportTimeoutMillis: 1, + cardinalityLimits: { default: defaultLimit }, + }); + + instrumentTypesValues.forEach(instrument => { + assert.strictEqual(p.selectCardinalityLimit(instrument), defaultLimit); + }); + }); + + it('should use the provided values for a given instrument type, or fallback to the default value otherwise', () => { + const exporter = new TestDeltaMetricExporter(); + + const p = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 1, + exportTimeoutMillis: 1, + cardinalityLimits: { + counter: 1000, + histogram: 3000, + observableCounter: 4000, + }, + }); + + assert.strictEqual( + p.selectCardinalityLimit(InstrumentType.COUNTER), + 1000 + ); + assert.strictEqual( + p.selectCardinalityLimit(InstrumentType.HISTOGRAM), + 3000 + ); + assert.strictEqual( + p.selectCardinalityLimit(InstrumentType.OBSERVABLE_COUNTER), + 4000 + ); + assert.strictEqual(p.selectCardinalityLimit(InstrumentType.GAUGE), 2000); + assert.strictEqual( + p.selectCardinalityLimit(InstrumentType.UP_DOWN_COUNTER), + 2000 + ); + assert.strictEqual( + p.selectCardinalityLimit(InstrumentType.OBSERVABLE_GAUGE), + 2000 + ); + assert.strictEqual( + p.selectCardinalityLimit(InstrumentType.OBSERVABLE_UP_DOWN_COUNTER), + 2000 + ); + }); + + it("should fallback to the default value if the specified cardinality selector doesn't exists", () => { + const exporter = new TestDeltaMetricExporter(); + const defaultLimit = 1; + + const p = new PeriodicExportingMetricReader({ + exporter, + exportIntervalMillis: 1, + exportTimeoutMillis: 1, + cardinalityLimits: { default: defaultLimit }, + }); + + assert.strictEqual( + p.selectCardinalityLimit('' as InstrumentType), + defaultLimit + ); + }); + }); + describe('setMetricProducer', () => { it('should start exporting periodically', async () => { const exporter = new TestMetricExporter();