diff --git a/CHANGELOG.md b/CHANGELOG.md index 8906fde42ce..fe251cabff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features +* feat(metrics): add advisory attributes parameter to metric instruments [#4365](https://github.com/open-telemetry/opentelemetry-js/issues/4365) + * Added experimental `attributes` parameter to `MetricAdvice` for filtering measurement attributes + * Only applies when no Views are configured for the instrument + * Views take precedence over advisory attributes when present + ### :bug: Bug Fixes * fix(sdk-metrics): Remove invalid default value for `startTime` param to ExponentialHistogramAccumulation. This only impacted the closurescript compiler. [#5763](https://github.com/open-telemetry/opentelemetry-js/pull/5763) @trentm diff --git a/api/CHANGELOG.md b/api/CHANGELOG.md index ff8842f2a71..022261dbd8c 100644 --- a/api/CHANGELOG.md +++ b/api/CHANGELOG.md @@ -9,6 +9,10 @@ All notable changes to this project will be documented in this file. ### :rocket: (Enhancement) +* feat(metrics): add advisory attributes parameter to metric instruments [#4365](https://github.com/open-telemetry/opentelemetry-js/issues/4365) + * Added experimental `attributes` parameter to `MetricAdvice` interface + * Allows specifying an allow-list of attribute keys for metric instruments + * feat(api): improve isValidSpanId, isValidTraceId performance [#5714](https://github.com/open-telemetry/opentelemetry-js/pull/5714) @seemk * feat(diag): change types in `DiagComponentLogger` from `any` to `unknown`[#5478](https://github.com/open-telemetry/opentelemetry-js/pull/5478) @loganrosen diff --git a/api/src/metrics/Metric.ts b/api/src/metrics/Metric.ts index b21aa7491fb..6692d787549 100644 --- a/api/src/metrics/Metric.ts +++ b/api/src/metrics/Metric.ts @@ -30,6 +30,18 @@ export interface MetricAdvice { * aggregated with a HistogramAggregator. */ explicitBucketBoundaries?: number[]; + + /** + * @experimental + * An allow-list of attribute keys for this instrument. Only these keys will be kept + * on measurements recorded by this instrument. If not provided, all attributes are kept. + * + * @example only keep 'service' and 'version' attributes + * attributes: ['service', 'version'] + * @example keep all attributes (default behavior) + * attributes: undefined + */ + attributes?: string[]; } /** diff --git a/packages/sdk-metrics/README.md b/packages/sdk-metrics/README.md index 035a1de01b2..705db572cf6 100644 --- a/packages/sdk-metrics/README.md +++ b/packages/sdk-metrics/README.md @@ -79,6 +79,29 @@ const meterProvider = new MeterProvider({ }) ``` +## Advisory Attributes (Experimental) + +The Metrics API supports an optional `attributes` advisory parameter in the `advice` configuration when creating instruments. This parameter acts as an allow-list for attribute keys, filtering out all attributes that are not in the specified list: + +```js +const counter = opentelemetry.metrics.getMeter('default').createCounter('filtered-counter', { + description: 'A counter with attribute filtering', + advice: { + attributes: ['service', 'version'], // @experimental: Only these keys will be kept + }, +}); + +// Only 'service' and 'version' attributes will be recorded, others are filtered out +counter.add(1, { + service: 'api-gateway', + version: '1.0.0', + region: 'us-west', // This will be filtered out + method: 'GET' // This will be filtered out +}); +``` + +**Note:** This feature is experimental and may change in future versions. The advisory attributes parameter only applies when no Views are configured for the instrument. If Views are present, they take precedence over the instrument's advisory attributes. + ## Example See [examples/prometheus](https://github.com/open-telemetry/opentelemetry-js/tree/main/experimental/examples/prometheus) for an end-to-end example, including exporting metrics. diff --git a/packages/sdk-metrics/src/view/ViewRegistry.ts b/packages/sdk-metrics/src/view/ViewRegistry.ts index 5f4f367e920..5638549dd4f 100644 --- a/packages/sdk-metrics/src/view/ViewRegistry.ts +++ b/packages/sdk-metrics/src/view/ViewRegistry.ts @@ -18,7 +18,8 @@ import { InstrumentationScope } from '@opentelemetry/core'; import { InstrumentDescriptor } from '../InstrumentDescriptor'; import { InstrumentSelector } from './InstrumentSelector'; import { MeterSelector } from './MeterSelector'; -import { View } from './View'; +import { View, ViewOptions } from './View'; +import { createAllowListAttributesProcessor } from './AttributesProcessor'; export class ViewRegistry { private _registeredViews: View[] = []; @@ -38,9 +39,41 @@ export class ViewRegistry { ); }); + // Only create a default view if advisory attributes are set and non-empty + if (views.length === 0) { + if ( + instrument.advice && + Array.isArray(instrument.advice.attributes) && + instrument.advice.attributes.length > 0 + ) { + return [this._createDefaultView(instrument)]; + } + // No matching views and no advisory attributes: return empty array for backward compatibility + return []; + } + return views; } + private _createDefaultView(instrument: InstrumentDescriptor): View { + const viewOptions: ViewOptions = { + instrumentName: instrument.name, + instrumentType: instrument.type, + instrumentUnit: instrument.unit, + }; + + if ( + instrument.advice.attributes && + instrument.advice.attributes.length > 0 + ) { + viewOptions.attributesProcessors = [ + createAllowListAttributesProcessor(instrument.advice.attributes), + ]; + } + + return new View(viewOptions); + } + private _matchInstrument( selector: InstrumentSelector, instrument: InstrumentDescriptor @@ -65,4 +98,4 @@ export class ViewRegistry { selector.getSchemaUrlFilter().match(meter.schemaUrl)) ); } -} +} \ No newline at end of file diff --git a/packages/sdk-metrics/test/advisory-attributes.integration.test.ts b/packages/sdk-metrics/test/advisory-attributes.integration.test.ts new file mode 100644 index 00000000000..9156c2642c5 --- /dev/null +++ b/packages/sdk-metrics/test/advisory-attributes.integration.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; +import { MeterProvider } from '../src/MeterProvider'; +import { TestMetricReader } from './export/TestMetricReader'; +import { + defaultInstrumentationScope, + testResource, +} from './util'; + +describe('Advisory attributes parameter - Integration test', () => { + let meterProvider: MeterProvider; + let metricReader: TestMetricReader; + + beforeEach(() => { + metricReader = new TestMetricReader(); + meterProvider = new MeterProvider({ + resource: testResource, + readers: [metricReader], + }); + }); + + afterEach(() => { + metricReader.shutdown(); + }); + + it('should filter attributes based on advisory attributes parameter', async () => { + const meter = meterProvider.getMeter( + defaultInstrumentationScope.name, + defaultInstrumentationScope.version, + { + schemaUrl: defaultInstrumentationScope.schemaUrl, + } + ); + + // Create counter with advisory attributes + const counter = meter.createCounter('test_counter', { + description: 'Test counter with advisory attributes', + advice: { + attributes: ['allowed_key1', 'allowed_key2'], // @experimental + }, + }); + + // Record measurements with various attributes + counter.add(1, { + allowed_key1: 'value1', + allowed_key2: 'value2', + filtered_key: 'filtered_value', // This should be filtered out + }); + + counter.add(2, { + allowed_key1: 'value3', + another_filtered_key: 'another_filtered_value', // This should be filtered out + }); + + // Collect metrics + const metrics = await metricReader.collect(); + + assert.strictEqual(metrics.resourceMetrics.scopeMetrics.length, 1); + + const scopeMetrics = metrics.resourceMetrics.scopeMetrics[0]; + assert.strictEqual(scopeMetrics.metrics.length, 1); + + const metric = scopeMetrics.metrics[0]; + assert.strictEqual(metric.descriptor.name, 'test_counter'); + assert.strictEqual(metric.dataPoints.length, 2); + + // Verify that only allowed attributes are present + const firstDataPoint = metric.dataPoints[0]; + assert.deepStrictEqual(firstDataPoint.attributes, { + allowed_key1: 'value1', + allowed_key2: 'value2', + }); + + const secondDataPoint = metric.dataPoints[1]; + assert.deepStrictEqual(secondDataPoint.attributes, { + allowed_key1: 'value3', + }); + }); + + it('should keep all attributes when no advisory attributes are specified', async () => { + const meter = meterProvider.getMeter( + defaultInstrumentationScope.name, + defaultInstrumentationScope.version, + { + schemaUrl: defaultInstrumentationScope.schemaUrl, + } + ); + + // Create counter without advisory attributes + const counter = meter.createCounter('test_counter_no_filter', { + description: 'Test counter without advisory attributes', + }); + + // Record measurements with various attributes + counter.add(1, { + key1: 'value1', + key2: 'value2', + key3: 'value3', + }); + + // Collect metrics + const metrics = await metricReader.collect(); + + assert.strictEqual(metrics.resourceMetrics.scopeMetrics.length, 1); + + const scopeMetrics = metrics.resourceMetrics.scopeMetrics[0]; + assert.strictEqual(scopeMetrics.metrics.length, 1); + + const metric = scopeMetrics.metrics[0]; + assert.strictEqual(metric.descriptor.name, 'test_counter_no_filter'); + assert.strictEqual(metric.dataPoints.length, 1); + + // Verify that all attributes are present + const dataPoint = metric.dataPoints[0]; + assert.deepStrictEqual(dataPoint.attributes, { + key1: 'value1', + key2: 'value2', + key3: 'value3', + }); + }); + + it('should work with different instrument types', async () => { + const meter = meterProvider.getMeter( + defaultInstrumentationScope.name, + defaultInstrumentationScope.version, + { + schemaUrl: defaultInstrumentationScope.schemaUrl, + } + ); + + // Test with Histogram + const histogram = meter.createHistogram('test_histogram', { + description: 'Test histogram with advisory attributes', + advice: { + attributes: ['service'], // @experimental + }, + }); + + histogram.record(10, { + service: 'api', + endpoint: '/users', // This should be filtered out + }); + + // Test with UpDownCounter + const upDownCounter = meter.createUpDownCounter('test_updown', { + description: 'Test updown counter with advisory attributes', + advice: { + attributes: ['region'], // @experimental + }, + }); + + upDownCounter.add(5, { + region: 'us-west', + instance: 'i-12345', // This should be filtered out + }); + + // Collect metrics + const metrics = await metricReader.collect(); + + assert.strictEqual(metrics.resourceMetrics.scopeMetrics.length, 1); + + const scopeMetrics = metrics.resourceMetrics.scopeMetrics[0]; + assert.strictEqual(scopeMetrics.metrics.length, 2); + + // Verify histogram filtering + const histogramMetric = scopeMetrics.metrics.find((m: any) => m.descriptor.name === 'test_histogram'); + assert.ok(histogramMetric); + assert.deepStrictEqual(histogramMetric.dataPoints[0].attributes, { + service: 'api', + }); + + // Verify updown counter filtering + const upDownMetric = scopeMetrics.metrics.find((m: any) => m.descriptor.name === 'test_updown'); + assert.ok(upDownMetric); + assert.deepStrictEqual(upDownMetric.dataPoints[0].attributes, { + region: 'us-west', + }); + }); +}); diff --git a/packages/sdk-metrics/test/view/ViewRegistry.test.ts b/packages/sdk-metrics/test/view/ViewRegistry.test.ts index bb1dadfbf01..194c0c04982 100644 --- a/packages/sdk-metrics/test/view/ViewRegistry.test.ts +++ b/packages/sdk-metrics/test/view/ViewRegistry.test.ts @@ -186,5 +186,136 @@ describe('ViewRegistry', () => { } }); }); + + describe('Advisory attributes parameter', () => { + it('should create default view with allow-list when no registered views match and instrument has advisory attributes', () => { + const registry = new ViewRegistry(); + + const instrumentWithAdvisoryAttributes = { + ...defaultInstrumentDescriptor, + name: 'test_instrument', + advice: { + attributes: ['key1', 'key2'] + } + }; + + const views = registry.findViews( + instrumentWithAdvisoryAttributes, + defaultInstrumentationScope + ); + + assert.strictEqual(views.length, 1); + assert.strictEqual(views[0].name, undefined); // Default view has no custom name + + // Test that the attributesProcessor is configured correctly + const processor = views[0].attributesProcessor; + const result = processor.process({ + key1: 'value1', + key2: 'value2', + key3: 'value3' // This should be filtered out + }); + + assert.deepStrictEqual(result, { + key1: 'value1', + key2: 'value2' + }); + }); + + it('should create default view without attribute filtering when instrument has no advisory attributes', () => { + const registry = new ViewRegistry(); + + const instrumentWithoutAdvisoryAttributes = { + ...defaultInstrumentDescriptor, + name: 'test_instrument_no_attributes', + advice: {} + }; + + const views = registry.findViews( + instrumentWithoutAdvisoryAttributes, + defaultInstrumentationScope + ); + + assert.strictEqual(views.length, 1); + + // Test that the attributesProcessor allows all attributes + const processor = views[0].attributesProcessor; + const inputAttrs = { + key1: 'value1', + key2: 'value2', + key3: 'value3' + }; + const result = processor.process(inputAttrs); + + assert.deepStrictEqual(result, inputAttrs); + }); + + it('should create default view without attribute filtering when instrument has empty advisory attributes', () => { + const registry = new ViewRegistry(); + + const instrumentWithEmptyAdvisoryAttributes = { + ...defaultInstrumentDescriptor, + name: 'test_instrument_empty_attributes', + advice: { + attributes: [] + } + }; + + const views = registry.findViews( + instrumentWithEmptyAdvisoryAttributes, + defaultInstrumentationScope + ); + + assert.strictEqual(views.length, 1); + + // Test that the attributesProcessor allows all attributes + const processor = views[0].attributesProcessor; + const inputAttrs = { + key1: 'value1', + key2: 'value2', + key3: 'value3' + }; + const result = processor.process(inputAttrs); + + assert.deepStrictEqual(result, inputAttrs); + }); + + it('should use registered views instead of advisory attributes when available', () => { + const registry = new ViewRegistry(); + + // Add a registered view + registry.addView(new View({ + name: 'custom_view', + instrumentName: 'test_instrument' + })); + + const instrumentWithAdvisoryAttributes = { + ...defaultInstrumentDescriptor, + name: 'test_instrument', + advice: { + attributes: ['key1', 'key2'] + } + }; + + const views = registry.findViews( + instrumentWithAdvisoryAttributes, + defaultInstrumentationScope + ); + + assert.strictEqual(views.length, 1); + assert.strictEqual(views[0].name, 'custom_view'); + + // The registered view should be used instead of advisory attributes + const processor = views[0].attributesProcessor; + const inputAttrs = { + key1: 'value1', + key2: 'value2', + key3: 'value3' + }; + const result = processor.process(inputAttrs); + + // Should allow all attributes since registered view doesn't have attribute filtering + assert.deepStrictEqual(result, inputAttrs); + }); + }); }); });