diff --git a/CHANGELOG.md b/CHANGELOG.md index 507cc3b7c2e..1ff31510543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features -* feat(sdk-logs): implement log creation metrics [#6433](https://github.com/open-telemetry/opentelemetry-js/pull/6433) @anuraaga +* feat(sdk-metrics): implement metric reader metrics [#6449](https://github.com/open-telemetry/opentelemetry-js/pull/6449) @anuraaga +* feat(core): add `hrTimeToSeconds` [#6449](https://github.com/open-telemetry/opentelemetry-js/pull/6449) @anuraaga +* feat(sdk-logs): implement log creation metrics [#6433](https://github.com/open-telemetry/opentelemetry-js/pull/6433) @anuraaga ### :bug: Bug Fixes diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts index 1436b129524..0ed2738eaae 100644 --- a/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts @@ -14,6 +14,7 @@ import type { IncomingMessage, Server, ServerResponse } from 'http'; import { createServer } from 'http'; import type { ExporterConfig } from './export/types'; import { PrometheusSerializer } from './PrometheusSerializer'; +import { OTEL_COMPONENT_TYPE_VALUE_PROMETHEUS_HTTP_TEXT_METRIC_EXPORTER } from './semconv'; /** Node.js v8.x compat */ import { URL } from 'url'; @@ -60,6 +61,8 @@ export class PrometheusExporter extends MetricReader { }, aggregationTemporalitySelector: _instrumentType => AggregationTemporality.CUMULATIVE, + otelComponentType: + OTEL_COMPONENT_TYPE_VALUE_PROMETHEUS_HTTP_TEXT_METRIC_EXPORTER, metricProducers: config.metricProducers, }); this._host = diff --git a/experimental/packages/opentelemetry-exporter-prometheus/src/semconv.ts b/experimental/packages/opentelemetry-exporter-prometheus/src/semconv.ts new file mode 100644 index 00000000000..a78c61f2679 --- /dev/null +++ b/experimental/packages/opentelemetry-exporter-prometheus/src/semconv.ts @@ -0,0 +1,20 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * This file contains a copy of unstable semantic convention definitions + * used by this package. + * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv + */ + +/** + * Enum value "prometheus_http_text_metric_exporter" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * Prometheus metric exporter over HTTP with the default text-based format + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_PROMETHEUS_HTTP_TEXT_METRIC_EXPORTER = + 'prometheus_http_text_metric_exporter' as const; diff --git a/experimental/packages/opentelemetry-sdk-node/README.md b/experimental/packages/opentelemetry-sdk-node/README.md index cf719ee7358..d2f2058e8b7 100644 --- a/experimental/packages/opentelemetry-sdk-node/README.md +++ b/experimental/packages/opentelemetry-sdk-node/README.md @@ -243,6 +243,7 @@ OTEL_NODE_EXPERIMENTAL_SDK_METRICS=true Currently a subset of the specified metrics are implemented. See the following linkes for details: +- Metric reader metrics: [MetricReaderMetrics](../../../packages//sdk-metrics/src/export/MetricReaderMetrics.ts) - Logger metrics: [LoggerMetrics.ts](../sdk-logs/src/LoggerMetrics.ts) - Span metrics: [TracerMetrics.ts](../../../packages/opentelemetry-sdk-trace-base/src/TracerMetrics.ts) diff --git a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts index d9e865515ed..4aa45c2cd26 100644 --- a/experimental/packages/opentelemetry-sdk-node/src/sdk.ts +++ b/experimental/packages/opentelemetry-sdk-node/src/sdk.ts @@ -327,6 +327,7 @@ export class NodeSDK { resource: this._resource, views: this._meterProviderConfig?.views ?? [], readers: this._meterProviderConfig.readers, + sdkMetricsEnabled, }); this._meterProvider = meterProvider; diff --git a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts index 212a2a5ef78..7f3a5899882 100644 --- a/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts +++ b/experimental/packages/opentelemetry-sdk-node/test/sdk.test.ts @@ -70,6 +70,7 @@ import { ZipkinExporter } from '@opentelemetry/exporter-zipkin'; import { NOOP_COUNTER_METRIC } from '../../../../api/src/metrics/NoopMeter'; import { ATTR_HOST_NAME, ATTR_PROCESS_PID } from '../src/semconv'; +import { NOOP_HISTOGRAM_METRIC } from '../../../../api/src/metrics/NoopMeter'; function assertDefaultContextManagerRegistered() { assert.ok( @@ -419,7 +420,7 @@ describe('Node SDK', () => { }); const sdk = new NodeSDK({ - metricReader: metricReader, + metricReaders: [metricReader], traceExporter: new ConsoleSpanExporter(), logRecordProcessors: [ new SimpleLogRecordProcessor(new InMemoryLogRecordExporter()), @@ -446,6 +447,10 @@ describe('Node SDK', () => { ); assert.ok(metrics.getMeterProvider() instanceof MeterProvider); + assert.notDeepEqual( + (metricReader as any)._metrics.collectionDuration, + NOOP_HISTOGRAM_METRIC + ); await sdk.shutdown(); }); @@ -459,7 +464,7 @@ describe('Node SDK', () => { }); const sdk = new NodeSDK({ - metricReader: metricReader, + metricReaders: [metricReader], traceExporter: new ConsoleSpanExporter(), logRecordProcessors: [ new SimpleLogRecordProcessor(new InMemoryLogRecordExporter()), @@ -484,6 +489,10 @@ describe('Node SDK', () => { ); assert.ok(metrics.getMeterProvider() instanceof MeterProvider); + assert.deepEqual( + (metricReader as any)._metrics.collectionDuration, + NOOP_HISTOGRAM_METRIC + ); await sdk.shutdown(); }); diff --git a/packages/opentelemetry-core/src/common/time.ts b/packages/opentelemetry-core/src/common/time.ts index 21402c319a9..cfdbca4ac6a 100644 --- a/packages/opentelemetry-core/src/common/time.ts +++ b/packages/opentelemetry-core/src/common/time.ts @@ -110,6 +110,14 @@ export function hrTimeToNanoseconds(time: api.HrTime): number { return time[0] * SECOND_TO_NANOSECONDS + time[1]; } +/** + * Convert hrTime to microseconds. + * @param time + */ +export function hrTimeToMicroseconds(time: api.HrTime): number { + return time[0] * 1e6 + time[1] / 1e3; +} + /** * Convert hrTime to milliseconds. * @param time @@ -119,13 +127,12 @@ export function hrTimeToMilliseconds(time: api.HrTime): number { } /** - * Convert hrTime to microseconds. + * Convert hrTime to seconds. * @param time */ -export function hrTimeToMicroseconds(time: api.HrTime): number { - return time[0] * 1e6 + time[1] / 1e3; +export function hrTimeToSeconds(time: api.HrTime): number { + return time[0] + time[1] / SECOND_TO_NANOSECONDS; } - /** * check if time is HrTime * @param value diff --git a/packages/opentelemetry-core/src/index.ts b/packages/opentelemetry-core/src/index.ts index 81456120b4a..214610fd22f 100644 --- a/packages/opentelemetry-core/src/index.ts +++ b/packages/opentelemetry-core/src/index.ts @@ -20,6 +20,7 @@ export { hrTimeToMicroseconds, hrTimeToMilliseconds, hrTimeToNanoseconds, + hrTimeToSeconds, hrTimeToTimeStamp, isTimeInput, isTimeInputHrTime, diff --git a/packages/opentelemetry-core/test/common/time.test.ts b/packages/opentelemetry-core/test/common/time.test.ts index ca8ff353c28..cfc6fae8288 100644 --- a/packages/opentelemetry-core/test/common/time.test.ts +++ b/packages/opentelemetry-core/test/common/time.test.ts @@ -13,8 +13,9 @@ import { timeInputToHrTime, hrTimeDuration, hrTimeToNanoseconds, - hrTimeToMilliseconds, hrTimeToMicroseconds, + hrTimeToMilliseconds, + hrTimeToSeconds, hrTimeToTimeStamp, isTimeInput, addHrTimes, @@ -164,6 +165,13 @@ describe('time', () => { }); }); + describe('#hrTimeToMicroseconds', () => { + it('should return microseconds', () => { + const output = hrTimeToMicroseconds([1, 200000000]); + assert.deepStrictEqual(output, 1200000); + }); + }); + describe('#hrTimeToMilliseconds', () => { it('should return milliseconds', () => { const output = hrTimeToMilliseconds([1, 200000000]); @@ -171,12 +179,13 @@ describe('time', () => { }); }); - describe('#hrTimeToMicroseconds', () => { - it('should return microseconds', () => { - const output = hrTimeToMicroseconds([1, 200000000]); - assert.deepStrictEqual(output, 1200000); + describe('#hrTimeToSeconds', () => { + it('should return seconds', () => { + const output = hrTimeToSeconds([1, 200000000]); + assert.deepStrictEqual(output, 1.2); }); }); + describe('#isTimeInput', () => { it('should return true for a number', () => { assert.strictEqual(isTimeInput(12), true); diff --git a/packages/sdk-metrics/src/MeterProvider.ts b/packages/sdk-metrics/src/MeterProvider.ts index 706a501d212..9dfd17eaf7f 100644 --- a/packages/sdk-metrics/src/MeterProvider.ts +++ b/packages/sdk-metrics/src/MeterProvider.ts @@ -11,7 +11,7 @@ import type { import { diag, createNoopMeter } from '@opentelemetry/api'; import type { Resource } from '@opentelemetry/resources'; import { defaultResource } from '@opentelemetry/resources'; -import type { IMetricReader } from './export/MetricReader'; +import { MetricReader, type IMetricReader } from './export/MetricReader'; import { MeterProviderSharedState } from './state/MeterProviderSharedState'; import { MetricCollector } from './state/MetricCollector'; import type { ForceFlushOptions, ShutdownOptions } from './types'; @@ -26,6 +26,12 @@ export interface MeterProviderOptions { resource?: Resource; views?: ViewOptions[]; readers?: IMetricReader[]; + + /** + * Whether to enable SDK metrics for this meter provider. + * @experimental This option is experimental and is subject to breaking changes in minor releases. + */ + sdkMetricsEnabled?: boolean; } /** @@ -50,6 +56,9 @@ export class MeterProvider implements IMeterProvider { const collector = new MetricCollector(this._sharedState, metricReader); metricReader.setMetricProducer(collector); this._sharedState.metricCollectors.push(collector); + if (options.sdkMetricsEnabled && metricReader instanceof MetricReader) { + metricReader._setMeterProvider(this); + } } } } diff --git a/packages/sdk-metrics/src/export/MetricReader.ts b/packages/sdk-metrics/src/export/MetricReader.ts index 6106ce21e69..24b778072be 100644 --- a/packages/sdk-metrics/src/export/MetricReader.ts +++ b/packages/sdk-metrics/src/export/MetricReader.ts @@ -23,6 +23,9 @@ import { } from './AggregationSelector'; import type { AggregationOption } from '../view/AggregationOption'; import type { CardinalitySelector } from './CardinalitySelector'; +import { MetricReaderMetrics } from './MetricReaderMetrics'; +import { VERSION } from '../version'; +import { hrTime, hrTimeDuration, hrTimeToSeconds } from '@opentelemetry/core'; export interface MetricReaderOptions { /** @@ -54,6 +57,11 @@ export interface MetricReaderOptions { * @experimental */ metricProducers?: MetricProducer[]; + /** + * The component type used for reporting SDK metrics. + * @experimental This option is experimental and is subject to breaking changes in minor releases. + */ + otelComponentType?: string; } /** @@ -132,9 +140,12 @@ export abstract class MetricReader implements IMetricReader { private _metricProducers: MetricProducer[]; // MetricProducer used by this instance which produces metrics from the SDK private _sdkMetricProducer?: MetricProducer; + // Metrics about the MetricReader itself + private _metrics: MetricReaderMetrics; private readonly _aggregationTemporalitySelector: AggregationTemporalitySelector; private readonly _aggregationSelector: AggregationSelector; private readonly _cardinalitySelector?: CardinalitySelector; + private readonly _otelComponentType: string; constructor(options?: MetricReaderOptions) { this._aggregationSelector = @@ -144,6 +155,12 @@ export abstract class MetricReader implements IMetricReader { DEFAULT_AGGREGATION_TEMPORALITY_SELECTOR; this._metricProducers = options?.metricProducers ?? []; this._cardinalitySelector = options?.cardinalitySelector; + this._otelComponentType = + options?.otelComponentType ?? this.constructor.name; + this._metrics = new MetricReaderMetrics( + this._otelComponentType, + api.createNoopMeter() + ); } setMetricProducer(metricProducer: MetricProducer) { @@ -163,6 +180,11 @@ export abstract class MetricReader implements IMetricReader { this.onInitialized(); } + _setMeterProvider(meterProvider: api.MeterProvider): void { + const meter = meterProvider.getMeter('@opentelemetry/sdk-metrics', VERSION); + this._metrics = new MetricReaderMetrics(this._otelComponentType, meter); + } + selectAggregation(instrumentType: InstrumentType): AggregationOption { return this._aggregationSelector(instrumentType); } @@ -214,6 +236,7 @@ export abstract class MetricReader implements IMetricReader { throw new Error('MetricReader is shutdown'); } + const startTime = hrTime(); const [sdkCollectionResults, ...additionalCollectionResults] = await Promise.all([ this._sdkMetricProducer.collect({ @@ -225,11 +248,21 @@ export abstract class MetricReader implements IMetricReader { }) ), ]); + const endTime = hrTime(); // Merge the results, keeping the SDK's Resource const errors = sdkCollectionResults.errors.concat( additionalCollectionResults.flatMap(result => result.errors) ); + + const collectDuration = hrTimeToSeconds(hrTimeDuration(startTime, endTime)); + this._metrics.recordCollection( + collectDuration, + errors.length > 0 + ? ((errors[0] as Error).name ?? 'collect_error') + : undefined + ); + const resource = sdkCollectionResults.resourceMetrics.resource; const scopeMetrics = sdkCollectionResults.resourceMetrics.scopeMetrics.concat( diff --git a/packages/sdk-metrics/src/export/MetricReaderMetrics.ts b/packages/sdk-metrics/src/export/MetricReaderMetrics.ts new file mode 100644 index 00000000000..2f3c771cdb3 --- /dev/null +++ b/packages/sdk-metrics/src/export/MetricReaderMetrics.ts @@ -0,0 +1,52 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Attributes, Histogram, Meter } from '@opentelemetry/api'; +import { + ATTR_ERROR_TYPE, + ATTR_OTEL_COMPONENT_NAME, + ATTR_OTEL_COMPONENT_TYPE, + METRIC_OTEL_SDK_METRIC_READER_COLLECTION_DURATION, +} from '../semconv'; + +const componentCounter = new Map(); + +/** + * Generates `otel.sdk.metric_reader.*` metrics. + * https://opentelemetry.io/docs/specs/semconv/otel/sdk-metrics/#metric-otelsdkmetric_readercollectionduration + */ +export class MetricReaderMetrics { + private readonly collectionDuration: Histogram; + private readonly standardAttrs: Attributes; + + constructor(componentType: string, meter: Meter) { + const counter = componentCounter.get(componentType) ?? 0; + componentCounter.set(componentType, counter + 1); + + this.standardAttrs = { + [ATTR_OTEL_COMPONENT_TYPE]: componentType, + [ATTR_OTEL_COMPONENT_NAME]: `${componentType}/${counter}`, + }; + + this.collectionDuration = meter.createHistogram( + METRIC_OTEL_SDK_METRIC_READER_COLLECTION_DURATION, + { + unit: 's', + description: + 'The duration of the collect operation of the metric reader.', + advice: { + explicitBucketBoundaries: [], + }, + } + ); + } + + recordCollection(durationSecs: number, error: string | undefined) { + const attrs = error + ? { ...this.standardAttrs, [ATTR_ERROR_TYPE]: error } + : this.standardAttrs; + this.collectionDuration.record(durationSecs, attrs); + } +} diff --git a/packages/sdk-metrics/src/export/PeriodicExportingMetricReader.ts b/packages/sdk-metrics/src/export/PeriodicExportingMetricReader.ts index df489066883..c36f81ff90c 100644 --- a/packages/sdk-metrics/src/export/PeriodicExportingMetricReader.ts +++ b/packages/sdk-metrics/src/export/PeriodicExportingMetricReader.ts @@ -12,8 +12,9 @@ import { import { MetricReader } from './MetricReader'; import type { PushMetricExporter } from './MetricExporter'; import { callWithTimeout, TimeoutError } from '../utils'; -import type { MetricProducer } from './MetricProducer'; import { InstrumentType } from './MetricData'; +import type { MetricProducer } from './MetricProducer'; +import { OTEL_COMPONENT_TYPE_VALUE_PERIODIC_METRIC_READER } from '../semconv'; export type PeriodicExportingMetricReaderOptions = { /** @@ -75,6 +76,7 @@ export class PeriodicExportingMetricReader extends MetricReader { aggregationSelector: exporter.selectAggregation?.bind(exporter), aggregationTemporalitySelector: exporter.selectAggregationTemporality?.bind(exporter), + otelComponentType: OTEL_COMPONENT_TYPE_VALUE_PERIODIC_METRIC_READER, metricProducers, cardinalitySelector: (instrumentType: InstrumentType) => { const limits = { diff --git a/packages/sdk-metrics/src/semconv.ts b/packages/sdk-metrics/src/semconv.ts new file mode 100644 index 00000000000..1610cca74a2 --- /dev/null +++ b/packages/sdk-metrics/src/semconv.ts @@ -0,0 +1,98 @@ +/* + * Copyright The OpenTelemetry Authors + * SPDX-License-Identifier: Apache-2.0 + */ + +/* + * This file contains a copy of unstable semantic convention definitions + * used by this package. + * @see https://github.com/open-telemetry/opentelemetry-js/tree/main/semantic-conventions#unstable-semconv + */ + +/** + * A name uniquely identifying the instance of the OpenTelemetry component within its containing SDK instance. + * + * @example otlp_grpc_span_exporter/0 + * @example custom-name + * + * @note Implementations **SHOULD** ensure a low cardinality for this attribute, even across application or SDK restarts. + * E.g. implementations **MUST NOT** use UUIDs as values for this attribute. + * + * Implementations **MAY** achieve these goals by following a `/` pattern, e.g. `batching_span_processor/0`. + * Hereby `otel.component.type` refers to the corresponding attribute value of the component. + * + * The value of `instance-counter` **MAY** be automatically assigned by the component and uniqueness within the enclosing SDK instance **MUST** be guaranteed. + * For example, `` **MAY** be implemented by using a monotonically increasing counter (starting with `0`), which is incremented every time an + * instance of the given component type is started. + * + * With this implementation, for example the first Batching Span Processor would have `batching_span_processor/0` + * as `otel.component.name`, the second one `batching_span_processor/1` and so on. + * These values will therefore be reused in the case of an application restart. + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_OTEL_COMPONENT_NAME = 'otel.component.name' as const; + +/** + * A name identifying the type of the OpenTelemetry component. + * + * @example batching_span_processor + * @example com.example.MySpanExporter + * + * @note If none of the standardized values apply, implementations **SHOULD** use the language-defined name of the type. + * E.g. for Java the fully qualified classname **SHOULD** be used in this case. + * + * @experimental This attribute is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const ATTR_OTEL_COMPONENT_TYPE = 'otel.component.type' as const; + +/** + * Enum value "periodic_metric_reader" for attribute {@link ATTR_OTEL_COMPONENT_TYPE}. + * + * The builtin SDK periodically exporting metric reader + * + * @experimental This enum value is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const OTEL_COMPONENT_TYPE_VALUE_PERIODIC_METRIC_READER = + 'periodic_metric_reader' as const; + +/** + * The duration of the collect operation of the metric reader. + * + * @note For successful collections, `error.type` **MUST NOT** be set. For failed collections, `error.type` **SHOULD** contain the failure cause. + * It can happen that metrics collection is successful for some MetricProducers, while others fail. In that case `error.type` **SHOULD** be set to any of the failure causes. + * + * @experimental This metric is experimental and is subject to breaking changes in minor releases of `@opentelemetry/semantic-conventions`. + */ +export const METRIC_OTEL_SDK_METRIC_READER_COLLECTION_DURATION = + 'otel.sdk.metric_reader.collection.duration' as const; + +/** + * Describes a class of error the operation ended with. + * + * @example timeout + * @example java.net.UnknownHostException + * @example server_certificate_invalid + * @example 500 + * + * @note The `error.type` **SHOULD** be predictable, and **SHOULD** have low cardinality. + * + * When `error.type` is set to a type (e.g., an exception type), its + * canonical class name identifying the type within the artifact **SHOULD** be used. + * + * Instrumentations **SHOULD** document the list of errors they report. + * + * The cardinality of `error.type` within one instrumentation library **SHOULD** be low. + * Telemetry consumers that aggregate data from multiple instrumentation libraries and applications + * should be prepared for `error.type` to have high cardinality at query time when no + * additional filters are applied. + * + * If the operation has completed successfully, instrumentations **SHOULD NOT** set `error.type`. + * + * If a specific domain defines its own set of error identifiers (such as HTTP or RPC status codes), + * it's **RECOMMENDED** to: + * + * - Use a domain-specific attribute + * - Set `error.type` to capture all errors, regardless of whether they are defined within the domain-specific set or not. + */ +export const ATTR_ERROR_TYPE = 'error.type' as const; diff --git a/packages/sdk-metrics/test/export/PeriodicExportingMetricReader.test.ts b/packages/sdk-metrics/test/export/PeriodicExportingMetricReader.test.ts index 35e19b0ece1..e3991b0665a 100644 --- a/packages/sdk-metrics/test/export/PeriodicExportingMetricReader.test.ts +++ b/packages/sdk-metrics/test/export/PeriodicExportingMetricReader.test.ts @@ -8,11 +8,11 @@ import { AggregationTemporality } from '../../src/export/AggregationTemporality' import type { AggregationOption, CollectionResult, + Histogram, MetricProducer, PushMetricExporter, } from '../../src'; -import { InstrumentType } from '../../src/export/MetricData'; -import { AggregationType } from '../../src'; +import { AggregationType, InstrumentType, MeterProvider } from '../../src'; import type { ResourceMetrics, ScopeMetrics, @@ -783,4 +783,105 @@ describe('PeriodicExportingMetricReader', () => { await assert.rejects(() => reader.shutdown(), /Error during forceFlush/); }); }); + + describe('sdk metrics', () => { + it('should record collection duration when meter provider set', async () => { + const exporter = new TestMetricExporter(); + const reader = new PeriodicExportingMetricReader({ + exporter: exporter, + exportIntervalMillis: 30, + exportTimeoutMillis: 20, + }); + const meterProvider = new MeterProvider({ + readers: [reader], + sdkMetricsEnabled: true, + }); + + const counter = meterProvider + .getMeter('test') + .createCounter('test_counter'); + counter.add(1); + + // First export will not have the collection metric, so wait for two. + const result = await exporter.waitForNumberOfExports(2); + assert.strictEqual(result.length, 2); + const scopeMetrics = result[1].scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/sdk-metrics' + ); + assert.ok(scopeMetrics); + const collectionDurationMetric = scopeMetrics.metrics.find( + m => m.descriptor.name === 'otel.sdk.metric_reader.collection.duration' + ); + assert.ok(collectionDurationMetric); + const histogram = collectionDurationMetric.dataPoints[0] + .value as Histogram; + assert.strictEqual(histogram.count, 1); + const attrs = collectionDurationMetric.dataPoints[0].attributes; + assert.strictEqual( + attrs['otel.component.type'], + 'periodic_metric_reader' + ); + assert.ok( + attrs['otel.component.name'] + ?.toString() + .startsWith('periodic_metric_reader/') + ); + assert.strictEqual(attrs['error.type'], undefined); + + await meterProvider.shutdown(); + }); + + it('should record collection error', async () => { + const exporter = new TestMetricExporter(); + const reader = new PeriodicExportingMetricReader({ + exporter: exporter, + exportIntervalMillis: 30, + exportTimeoutMillis: 20, + }); + const meterProvider = new MeterProvider({ + readers: [reader], + sdkMetricsEnabled: true, + }); + + meterProvider + .getMeter('test') + .createObservableCounter('bad_counter') + .addCallback(() => { + throw new Error('bad metric'); + }); + + const counter = meterProvider + .getMeter('test') + .createCounter('test_counter'); + counter.add(1); + + // First export will not have the collection metric, so wait for two. + const result = await exporter.waitForNumberOfExports(2); + assert.strictEqual(result.length, 2); + const scopeMetrics = result[1].scopeMetrics.find( + sm => sm.scope.name === '@opentelemetry/sdk-metrics' + ); + assert.ok(scopeMetrics); + const collectionDurationMetric = scopeMetrics.metrics.find( + m => m.descriptor.name === 'otel.sdk.metric_reader.collection.duration' + ); + assert.ok(collectionDurationMetric); + const histogram = collectionDurationMetric.dataPoints[0] + .value as Histogram; + assert.strictEqual(histogram.count, 1); + const attrs = collectionDurationMetric.dataPoints[0].attributes; + assert.strictEqual( + attrs['otel.component.type'], + 'periodic_metric_reader' + ); + assert.ok( + attrs['otel.component.name'] + ?.toString() + .startsWith('periodic_metric_reader/') + ); + assert.strictEqual(attrs['error.type'], 'Error'); + + await meterProvider.shutdown(); + }); + }); });