diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/AlignedHistogramBucketExemplarReservoir.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/AlignedHistogramBucketExemplarReservoir.ts new file mode 100644 index 00000000000..9c09abf9cfe --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/AlignedHistogramBucketExemplarReservoir.ts @@ -0,0 +1,49 @@ +/* + * 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 { Context, HrTime } from '@opentelemetry/api'; +import { Attributes } from '@opentelemetry/api-metrics-wip'; +import { FixedSizeExemplarReservoirBase } from './ExemplarReservoir'; + + +/** + * AlignedHistogramBucketExemplarReservoir takes the same boundaries + * configuration of a Histogram. This alogorithm keeps the last seen measurement + * that falls within a histogram bucket. + * /~https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/metrics/sdk.md#exemplar-defaults + */ +export class AlignedHistogramBucketExemplarReservoir extends FixedSizeExemplarReservoirBase { + private _boundaries: number[]; + constructor(boundaries: number[]) { + super(boundaries.length+1); + this._boundaries = boundaries; + } + + private _findBucketIndex(value: number, _timestamp: HrTime, _attributes: Attributes, _ctx: Context) { + for(let i = 0; i < this._boundaries.length; i++) { + if (value <= this._boundaries[i]) { + return i; + } + } + return this._boundaries.length; + } + + offer(value: number, timestamp: HrTime, attributes: Attributes, ctx: Context): void { + const index = this._findBucketIndex(value, timestamp, attributes, ctx); + this._reservoirStorage[index].offer(value, timestamp, attributes, ctx); + } +} diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/AlwaysSampleExemplarFilter.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/AlwaysSampleExemplarFilter.ts new file mode 100644 index 00000000000..335da937fb7 --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/AlwaysSampleExemplarFilter.ts @@ -0,0 +1,32 @@ +/* + * 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 { Attributes } from '@opentelemetry/api-metrics-wip' +import { Context, HrTime } from '@opentelemetry/api' +import { ExemplarFilter } from './ExemplarFilter'; + + +export class AlwaysSampleExemplarFilter implements ExemplarFilter { + + shouldSample( + _value: number, + _timestamp: HrTime, + _attributes: Attributes, + _ctx: Context + ): boolean { + return true; + } +} diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/Exemplar.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/Exemplar.ts new file mode 100644 index 00000000000..8b874b75203 --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/Exemplar.ts @@ -0,0 +1,47 @@ +/* + * 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 { HrTime } from '@opentelemetry/api'; +import { Attributes } from '@opentelemetry/api-metrics-wip'; + +/** + * A representation of an exemplar, which is a sample input measurement. + * Exemplars also hold information about the environment when the measurement + * was recorded, for example the span and trace ID of the active span when the + * exemplar was recorded. + */ +export type Exemplar = { + // The set of key/value pairs that were filtered out by the aggregator, but + // recorded alongside the original measurement. Only key/value pairs that were + // filtered out by the aggregator should be included + filteredAttributes: Attributes; + + // The value of the measurement that was recorded. + value: number; + + // timestamp is the exact time when this exemplar was recorded + timestamp: HrTime; + + // (Optional) Span ID of the exemplar trace. + // span_id may be missing if the measurement is not recorded inside a trace + // or if the trace is not sampled. + spanId?: string; + + // (Optional) Trace ID of the exemplar trace. + // trace_id may be missing if the measurement is not recorded inside a trace + // or if the trace is not sampled. + traceId?: string; +} diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/ExemplarFilter.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/ExemplarFilter.ts new file mode 100644 index 00000000000..435e3b37f97 --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/ExemplarFilter.ts @@ -0,0 +1,40 @@ +/* + * 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 { Attributes } from '@opentelemetry/api-metrics-wip' +import { Context, HrTime } from '@opentelemetry/api' + +/** + * This interface represents a ExemplarFilter. Exemplar filters are + * used to filter measurements before attempting to store them in a + * reservoir. + */ +export interface ExemplarFilter { + /** + * Returns whether or not a reservoir should attempt to filter a measurement. + * + * @param value The value of the measurement + * @param timestamp A timestamp that best represents when the measurement was taken + * @param attributes The complete set of Attributes of the measurement + * @param context The Context of the measurement + */ + shouldSample( + value: number, + timestamp: HrTime, + attributes: Attributes, + ctx: Context + ): boolean; +} diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/ExemplarReservoir.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/ExemplarReservoir.ts new file mode 100644 index 00000000000..09da8d4603a --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/ExemplarReservoir.ts @@ -0,0 +1,128 @@ +/* + * 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 { Attributes } from '@opentelemetry/api-metrics-wip' +import { Context, HrTime, isSpanContextValid, trace } from '@opentelemetry/api' +import { Exemplar } from './Exemplar' + + +/** + * An interface for an exemplar reservoir of samples. + */ +export interface ExemplarReservoir { + + /** Offers a measurement to be sampled. */ + offer( + value: number, + timestamp: HrTime, + attributes: Attributes, + ctx: Context + ): void; + /** + * Returns accumulated Exemplars and also resets the reservoir + * for the next sampling period + * + * @param pointAttributes The attributes associated with metric point. + * + * @returns a list of {@link Exemplar}s. Retuned exemplars contain the attributes that were filtered out by the + * aggregator, but recorded alongside the original measurement. + */ + collect(pointAttributes: Attributes): Exemplar[]; +} + + +class ExemplarBucket { + private value: number = 0; + private attributes: Attributes = {}; + private timestamp: HrTime = [0, 0]; + private spanId?: string; + private traceId?: string; + private _offered: boolean = false; + + offer(value: number, timestamp: HrTime, attributes: Attributes, ctx: Context) { + this.value = value; + this.timestamp = timestamp; + this.attributes = attributes; + const spanContext = trace.getSpanContext(ctx); + if (spanContext && isSpanContextValid(spanContext)) { + this.spanId = spanContext.spanId; + this.traceId = spanContext.traceId; + } + this._offered = true; + } + + collect(pointAttributes: Attributes): Exemplar | null { + if (!this._offered) return null; + const currentAttributes = this.attributes; + // filter attributes + Object.keys(pointAttributes).forEach(key => { + if (pointAttributes[key] === currentAttributes[key]) { + delete currentAttributes[key]; + } + }); + const retVal: Exemplar = { + filteredAttributes: currentAttributes, + value: this.value, + timestamp: this.timestamp, + spanId: this.spanId, + traceId: this.traceId + }; + this.attributes = {}; + this.value = 0; + this.timestamp = [0, 0]; + this.spanId = undefined; + this.traceId = undefined; + this._offered = false; + return retVal; + } +} + + +export abstract class FixedSizeExemplarReservoirBase implements ExemplarReservoir { + protected _reservoirStorage: ExemplarBucket[]; + protected _size: number; + + constructor(size: number) { + this._size = size; + this._reservoirStorage = new Array(size); + for(let i = 0; i < this._size; i++) { + this._reservoirStorage[i] = new ExemplarBucket(); + } + } + + abstract offer(value: number, timestamp: HrTime, attributes: Attributes, ctx: Context): void; + + maxSize(): number { + return this._size; + } + + /** + * Resets the reservoir + */ + protected reset(): void {} + + collect(pointAttributes: Attributes): Exemplar[] { + const exemplars: Exemplar[] = []; + this._reservoirStorage.forEach(storageItem => { + const res = storageItem.collect(pointAttributes); + if (res !== null) { + exemplars.push(res); + } + }); + this.reset(); + return exemplars; + } +} diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/NeverSampleExemplarFilter.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/NeverSampleExemplarFilter.ts new file mode 100644 index 00000000000..b20ea116e9e --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/NeverSampleExemplarFilter.ts @@ -0,0 +1,31 @@ +/* + * 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 { Attributes } from '@opentelemetry/api-metrics-wip' +import { Context, HrTime } from '@opentelemetry/api' +import { ExemplarFilter } from './ExemplarFilter'; + +export class NeverSampleExemplarFilter implements ExemplarFilter { + + shouldSample( + _value: number, + _timestamp: HrTime, + _attributes: Attributes, + _ctx: Context + ): boolean { + return false; + } +} diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/SimpleFixedSizeExemplarReservoir.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/SimpleFixedSizeExemplarReservoir.ts new file mode 100644 index 00000000000..4184e09f377 --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/SimpleFixedSizeExemplarReservoir.ts @@ -0,0 +1,53 @@ +/* + * 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 { Context, HrTime } from '@opentelemetry/api'; +import { Attributes } from '@opentelemetry/api-metrics-wip'; +import { FixedSizeExemplarReservoirBase } from './ExemplarReservoir'; + +/** + * Fixed size reservoir that uses equivalent of naive reservoir sampling + * algorithm to accept measurements. + * + */ +export class SimpleFixedSizeExemplarReservoir extends FixedSizeExemplarReservoirBase { + private _numMeasurementsSeen: number; + constructor(size: number) { + super(size); + this._numMeasurementsSeen = 0; + } + + private getRandomInt(min: number, max: number) { //[min, max) + return Math.floor(Math.random() * (max - min) + min); + } + + private _findBucketIndex(_value: number, _timestamp: HrTime, _attributes: Attributes, _ctx: Context) { + if (this._numMeasurementsSeen < this._size ) return this._numMeasurementsSeen++; + const index = this.getRandomInt(0, ++this._numMeasurementsSeen); + return index < this._size ? index: -1; + } + + offer(value: number, timestamp: HrTime, attributes: Attributes, ctx: Context): void { + const index = this._findBucketIndex(value, timestamp, attributes, ctx); + if (index !== -1) { + this._reservoirStorage[index].offer(value, timestamp, attributes, ctx) + } + } + + override reset() { + this._numMeasurementsSeen = 0; + } +} diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/WithTraceExemplarFilter.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/WithTraceExemplarFilter.ts new file mode 100644 index 00000000000..f9f75e4ddd1 --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/WithTraceExemplarFilter.ts @@ -0,0 +1,34 @@ +/* + * 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 { Attributes } from '@opentelemetry/api-metrics-wip' +import { Context, HrTime, isSpanContextValid, trace, TraceFlags } from '@opentelemetry/api' +import { ExemplarFilter } from './ExemplarFilter'; + +export class WithTraceExemplarFilter implements ExemplarFilter { + + shouldSample( + value: number, + timestamp: HrTime, + attributes: Attributes, + ctx: Context + ): boolean { + const spanContext = trace.getSpanContext(ctx); + if (!spanContext || !isSpanContextValid(spanContext)) + return false; + return spanContext.traceFlags & TraceFlags.SAMPLED ? true : false; + } +} diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/index.ts b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/index.ts new file mode 100644 index 00000000000..b9e84259fff --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/src/exemplar/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export * from './Exemplar'; +export * from './ExemplarFilter'; +export * from './AlwaysSampleExemplarFilter'; +export * from './NeverSampleExemplarFilter'; +export * from './WithTraceExemplarFilter'; +export * from './ExemplarReservoir'; +export * from './AlignedHistogramBucketExemplarReservoir'; +export * from './SimpleFixedSizeExemplarReservoir'; diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/test/ExemplarFilter.test.ts b/experimental/packages/opentelemetry-sdk-metrics-base/test/ExemplarFilter.test.ts new file mode 100644 index 00000000000..22b86639ec1 --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/test/ExemplarFilter.test.ts @@ -0,0 +1,68 @@ +/* + * 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 { ROOT_CONTEXT, SpanContext, TraceFlags, trace } from '@opentelemetry/api'; + +import { + AlwaysSampleExemplarFilter, + NeverSampleExemplarFilter, + WithTraceExemplarFilter +} from '../src/exemplar/'; + + +describe('ExemplarFilter', () => { + const TRACE_ID = 'd4cda95b652f4a1592b449d5929fda1b'; + const SPAN_ID = '6e0c63257de34c92'; + + describe('AlwaysSampleExemplarFilter', () => { + it('should return true always for shouldSample', () => { + const filter = new AlwaysSampleExemplarFilter(); + assert.strictEqual(filter.shouldSample(10, [0, 0], {}, ROOT_CONTEXT), true); + }); + }); + + describe('NeverSampleExemplarFilter', () => { + it('should return false always for shouldSample', () => { + const filter = new NeverSampleExemplarFilter() + assert.strictEqual(filter.shouldSample(1, [0, 0], {}, ROOT_CONTEXT), false); + }); + }); + + describe('WithTraceExemplarFilter', () => { + it('should return false for shouldSample when the trace is not sampled', () => { + const filter = new WithTraceExemplarFilter(); + const spanContext: SpanContext = { + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: TraceFlags.NONE, + }; + const ctx = trace.setSpanContext(ROOT_CONTEXT, spanContext) + assert.strictEqual(filter.shouldSample(5.3, [0, 0,], {}, ctx), false); + }); + + it('should return true for shouldSample when the trace is sampled', () => { + const filter = new WithTraceExemplarFilter(); + const spanContext: SpanContext = { + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: TraceFlags.SAMPLED, + }; + const ctx = trace.setSpanContext(ROOT_CONTEXT, spanContext) + assert.strictEqual(filter.shouldSample(5.3, [0, 0,], {}, ctx), true); + }); + }); +}); diff --git a/experimental/packages/opentelemetry-sdk-metrics-base/test/ExemplarReservoir.test.ts b/experimental/packages/opentelemetry-sdk-metrics-base/test/ExemplarReservoir.test.ts new file mode 100644 index 00000000000..bfd2e5f4042 --- /dev/null +++ b/experimental/packages/opentelemetry-sdk-metrics-base/test/ExemplarReservoir.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { ROOT_CONTEXT, SpanContext, TraceFlags, trace } from '@opentelemetry/api'; +import { hrTime } from '@opentelemetry/core'; +import * as assert from 'assert' + +import { + SimpleFixedSizeExemplarReservoir, + AlignedHistogramBucketExemplarReservoir, +} from '../src/exemplar'; + +describe('ExemplarReservoir', () => { + + const TRACE_ID = 'd4cda95b652f4a1592b449d5929fda1b'; + const SPAN_ID = '6e0c63257de34c92'; + + describe('SimpleFixedSizeExemplarReservoir', () => { + it('should not return any result without measurements', () => { + const reservoir = new SimpleFixedSizeExemplarReservoir(10); + assert.strictEqual(reservoir.collect({}).length, 0); + }); + + it('should have the trace context information', () => { + const reservoir = new SimpleFixedSizeExemplarReservoir(1); + const spanContext: SpanContext = { + traceId: TRACE_ID, + spanId: SPAN_ID, + traceFlags: TraceFlags.SAMPLED, + }; + const ctx = trace.setSpanContext(ROOT_CONTEXT, spanContext) + + reservoir.offer(1, hrTime(), {}, ctx); + const exemplars = reservoir.collect({}); + assert.strictEqual(exemplars.length, 1); + assert.strictEqual(exemplars[0].traceId, TRACE_ID); + assert.strictEqual(exemplars[0].spanId, SPAN_ID); + }); + + }); + + it('should filter the attributes', () => { + const reservoir = new SimpleFixedSizeExemplarReservoir(1); + reservoir.offer(1, hrTime(), {'key1': 'value1', 'key2': 'value2'}, ROOT_CONTEXT); + const exemplars = reservoir.collect({'key2': 'value2', 'key3': 'value3'}); + assert.notStrictEqual(exemplars[0].filteredAttributes, {'key1': 'value1'}); + }); + + describe('AlignedHistogramBucketExemplarReservoir', () => { + it('should put measurements into buckets', () => { + const reservoir = new AlignedHistogramBucketExemplarReservoir([0, 5, 10, 25, 50, 75]); + reservoir.offer(52, hrTime(), {'bucket': '5'}, ROOT_CONTEXT); + reservoir.offer(7, hrTime(), {'bucket': '3'}, ROOT_CONTEXT); + reservoir.offer(6, hrTime(), {'bucket': '3'}, ROOT_CONTEXT); + const exemplars = reservoir.collect({'bucket': '3'}); + assert.strictEqual(exemplars.length, 2); + assert.strictEqual(exemplars[0].value, 6); + assert.strictEqual(Object.keys(exemplars[0].filteredAttributes).length, 0); + assert.strictEqual(exemplars[1].value, 52); + assert.notStrictEqual(exemplars[1].filteredAttributes, {'bucket':'5'}); + }); + }); +});