Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
621f6f6
feat: adds cardinalitySelector option in the PeriodicExportingMetricR…
starzlocker Mar 2, 2026
c7c9cca
tests: add unit tests to assert the functionality of the new argument
starzlocker Mar 2, 2026
f2f3562
refactor: allow cardinalityLimits to be passed to constructor
starzlocker Mar 19, 2026
6de4deb
tests: adjusts tests for the cardinalityLimits parameter
starzlocker Mar 19, 2026
712f263
Merge branch 'main' into feat/adds_cardinality_selector_opt
starzlocker Mar 19, 2026
c8dde4e
fix: small typo in cardinalityLimits default values
starzlocker Mar 19, 2026
38a120e
Merge branch 'feat/adds_cardinality_selector_opt' of github.com:starz…
starzlocker Mar 19, 2026
03a4bba
fix: instrument type being imported as a type
starzlocker Mar 19, 2026
eb62521
fix: white space
starzlocker Mar 19, 2026
cf40adb
refactor: adds all the instrument types scenarios to the cardinality …
starzlocker Apr 2, 2026
cc10bfa
refactor: improves test name
starzlocker Apr 2, 2026
62b20c2
fix: fixes the use of the default value
starzlocker Apr 8, 2026
98d2c46
tests: adds more tests to increase coverage and test different cases
starzlocker Apr 8, 2026
fb067a4
fix: removes .only from describe
starzlocker Apr 8, 2026
b934c8b
refactor: alters test to use a forEach approach
starzlocker Apr 8, 2026
9ca22be
fix: apply suggestions from code review
starzlocker Apr 8, 2026
ef66406
docs: added changelog entry
starzlocker Apr 9, 2026
6b9f8d4
docs: readme
starzlocker Apr 9, 2026
75696cb
fix: linting
starzlocker Apr 9, 2026
f5c11e3
Apply suggestion from @maryliag
maryliag Apr 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 73 additions & 11 deletions packages/sdk-metrics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,28 +33,34 @@ 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' });
```

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',
});
}
```

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand All @@ -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;
};
};

/**
Expand All @@ -48,14 +63,44 @@ 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({
aggregationSelector: exporter.selectAggregation?.bind(exporter),
aggregationTemporalitySelector:
exporter.selectAggregationTemporality?.bind(exporter),
metricProducers,
cardinalitySelector: (instrumentType: InstrumentType) => {
const limits = {
Comment thread
maryliag marked this conversation as resolved.
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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', () => {
Comment thread
starzlocker marked this conversation as resolved.
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", () => {
Comment thread
starzlocker marked this conversation as resolved.
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();
Expand Down
Loading