diff --git a/experimental/CHANGELOG.md b/experimental/CHANGELOG.md index 5ea01ee69c2..a6b668203a9 100644 --- a/experimental/CHANGELOG.md +++ b/experimental/CHANGELOG.md @@ -15,7 +15,9 @@ For notes on migrating to 2.x / 0.200.x see [the upgrade guide](doc/upgrade-to-2 ### :rocket: Features -feat(configuration): parse config for rc 3 [#6304](https://github.com/open-telemetry/opentelemetry-js/pull/6304) @maryliag +* feat(sampler-composite): add ComposableAnnotatingSampler and ComposableRuleBasedSampler [#6305](https://github.com/open-telemetry/opentelemetry-js/pull/6305) @trentm +* feat(configuration): parse config for rc 3 [#6304](https://github.com/open-telemetry/opentelemetry-js/pull/6304) @maryliag +* feat(instrumentation): use the `internals: true` option with import-in-the-middle hook, allowing instrumentations to hook internal files in ES modules [#6344](https://github.com/open-telemetry/opentelemetry-js/pull/6344) @trentm ### :bug: Bug Fixes @@ -38,7 +40,6 @@ feat(configuration): parse config for rc 3 [#6304](https://github.com/open-telem ### :rocket: Features -* feat(instrumentation): use the `internals: true` option with import-in-the-middle hook, allowing instrumentations to hook internal files in ES modules [#6344](https://github.com/open-telemetry/opentelemetry-js/pull/6344) @trentm * feat(sdk-logs): export event name from ConsoleLogRecordExporter [#6310](https://github.com/open-telemetry/opentelemetry-js/pull/6310) @aicest ### :bug: Bug Fixes diff --git a/experimental/packages/sampler-composite/src/alwaysoff.ts b/experimental/packages/sampler-composite/src/alwaysoff.ts index 2c41635cdee..58b44debf2d 100644 --- a/experimental/packages/sampler-composite/src/alwaysoff.ts +++ b/experimental/packages/sampler-composite/src/alwaysoff.ts @@ -16,10 +16,10 @@ import type { ComposableSampler, SamplingIntent } from './types'; import { INVALID_THRESHOLD } from './util'; -const intent: SamplingIntent = { +const intent: SamplingIntent = Object.freeze({ threshold: INVALID_THRESHOLD, thresholdReliable: false, -}; +}); class ComposableAlwaysOffSampler implements ComposableSampler { getSamplingIntent(): SamplingIntent { diff --git a/experimental/packages/sampler-composite/src/alwayson.ts b/experimental/packages/sampler-composite/src/alwayson.ts index 832d81e665b..e3a50d3c61a 100644 --- a/experimental/packages/sampler-composite/src/alwayson.ts +++ b/experimental/packages/sampler-composite/src/alwayson.ts @@ -16,10 +16,10 @@ import type { ComposableSampler, SamplingIntent } from './types'; import { MIN_THRESHOLD } from './util'; -const intent: SamplingIntent = { +const intent: SamplingIntent = Object.freeze({ threshold: MIN_THRESHOLD, thresholdReliable: true, -}; +}); class ComposableAlwaysOnSampler implements ComposableSampler { getSamplingIntent(): SamplingIntent { diff --git a/experimental/packages/sampler-composite/src/annotating.ts b/experimental/packages/sampler-composite/src/annotating.ts new file mode 100644 index 00000000000..9092626496c --- /dev/null +++ b/experimental/packages/sampler-composite/src/annotating.ts @@ -0,0 +1,69 @@ +/* + * 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, Context, Link, SpanKind } from '@opentelemetry/api'; +import type { ComposableSampler, SamplingIntent } from './types'; + +class ComposableAnnotatingSampler implements ComposableSampler { + private readonly delegate: ComposableSampler; + private readonly attributes: Attributes; + private readonly description: string; + + constructor(delegate: ComposableSampler, attributes: Attributes) { + // Shallow copy `attributes` to avoid changes to the original object (at + // least top-level fields) impacting the sampler. Freeze the object so + // we can return it in `getSamplingIntent` without copying there. + this.attributes = Object.freeze({ ...attributes }); + this.delegate = delegate; + this.description = `ComposableAnnotatingSampler(${delegate}, attributes)`; + } + + getSamplingIntent( + context: Context, + traceId: string, + spanName: string, + spanKind: SpanKind, + attributes: Attributes, + links: Link[] + ): SamplingIntent { + const intent = this.delegate.getSamplingIntent( + context, + traceId, + spanName, + spanKind, + attributes, + links + ); + return { + threshold: intent.threshold, + thresholdReliable: intent.thresholdReliable, + attributes: intent.attributes + ? { ...intent.attributes, ...this.attributes } + : this.attributes, + updateTraceState: intent.updateTraceState, + }; + } + + toString(): string { + return this.description; + } +} + +export function createComposableAnnotatingSampler( + delegate: ComposableSampler, + attributes: Attributes +): ComposableSampler { + return new ComposableAnnotatingSampler(delegate, attributes); +} diff --git a/experimental/packages/sampler-composite/src/index.ts b/experimental/packages/sampler-composite/src/index.ts index 91e68bf22e6..8462c6210be 100644 --- a/experimental/packages/sampler-composite/src/index.ts +++ b/experimental/packages/sampler-composite/src/index.ts @@ -18,5 +18,7 @@ export { createComposableAlwaysOffSampler } from './alwaysoff'; export { createComposableAlwaysOnSampler } from './alwayson'; export { createComposableTraceIDRatioBasedSampler } from './traceidratio'; export { createComposableParentThresholdSampler } from './parentthreshold'; +export { createComposableAnnotatingSampler } from './annotating'; +export { createComposableRuleBasedSampler } from './rulebased'; export { createCompositeSampler } from './composite'; export type { ComposableSampler, SamplingIntent } from './types'; diff --git a/experimental/packages/sampler-composite/src/rulebased.ts b/experimental/packages/sampler-composite/src/rulebased.ts new file mode 100644 index 00000000000..559f5e46219 --- /dev/null +++ b/experimental/packages/sampler-composite/src/rulebased.ts @@ -0,0 +1,71 @@ +/* + * 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, Context, Link, SpanKind } from '@opentelemetry/api'; + +import type { ComposableSampler, SamplingIntent, SamplingRule } from './types'; +import { INVALID_THRESHOLD } from './util'; + +const NON_SAMPLING_INTENT: SamplingIntent = Object.freeze({ + threshold: INVALID_THRESHOLD, + thresholdReliable: false, +}); + +class ComposableRuleBasedSampler implements ComposableSampler { + private readonly rules: SamplingRule[]; + private readonly description: string; + + constructor(rules: SamplingRule[]) { + this.rules = rules; + const ruleReprs = rules.map( + rule => `(${rule[0].name || '(anonymous)'}, ${rule[1].toString()})` + ); + this.description = `ComposableRuleBasedSampler([${ruleReprs.join(', ')}])`; + } + + getSamplingIntent( + context: Context, + traceId: string, + spanName: string, + spanKind: SpanKind, + attributes: Attributes, + links: Link[] + ): SamplingIntent { + for (const [predicate, sampler] of this.rules) { + if (predicate(context, traceId, spanName, spanKind, attributes, links)) { + return sampler.getSamplingIntent( + context, + traceId, + spanName, + spanKind, + attributes, + links + ); + } + } + return NON_SAMPLING_INTENT; + } + + toString(): string { + return this.description; + } +} + +export function createComposableRuleBasedSampler( + rules: SamplingRule[] +): ComposableSampler { + return new ComposableRuleBasedSampler(rules); +} diff --git a/experimental/packages/sampler-composite/src/traceidratio.ts b/experimental/packages/sampler-composite/src/traceidratio.ts index 85f342c8c14..f40ce268b30 100644 --- a/experimental/packages/sampler-composite/src/traceidratio.ts +++ b/experimental/packages/sampler-composite/src/traceidratio.ts @@ -32,18 +32,18 @@ class ComposableTraceIDRatioBasedSampler implements ComposableSampler { const thresholdStr = threshold === MAX_THRESHOLD ? 'max' : serializeTh(threshold); if (threshold !== MAX_THRESHOLD) { - this.intent = { + this.intent = Object.freeze({ threshold: threshold, thresholdReliable: true, - }; + }); } else { // Same as AlwaysOff, notably the threshold is not considered reliable. The spec mentions // returning an instance of ComposableAlwaysOffSampler in this case but it seems clearer // if the description of the sampler matches the user's request. - this.intent = { + this.intent = Object.freeze({ threshold: INVALID_THRESHOLD, thresholdReliable: false, - }; + }); } this.description = `ComposableTraceIDRatioBasedSampler(threshold=${thresholdStr}, ratio=${ratio})`; } diff --git a/experimental/packages/sampler-composite/src/types.ts b/experimental/packages/sampler-composite/src/types.ts index 3743a38c071..e4f08d293ce 100644 --- a/experimental/packages/sampler-composite/src/types.ts +++ b/experimental/packages/sampler-composite/src/types.ts @@ -42,3 +42,8 @@ export interface ComposableSampler { /** Returns the sampler name or short description with the configuration. */ toString(): string; } + +export type SamplingPredicate = ( + ...args: Parameters +) => boolean; +export type SamplingRule = [SamplingPredicate, ComposableSampler]; diff --git a/experimental/packages/sampler-composite/test/annotating.test.ts b/experimental/packages/sampler-composite/test/annotating.test.ts new file mode 100644 index 00000000000..91a88dfc631 --- /dev/null +++ b/experimental/packages/sampler-composite/test/annotating.test.ts @@ -0,0 +1,87 @@ +/* + * 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 { context, SpanKind } from '@opentelemetry/api'; + +import { + createComposableAlwaysOnSampler, + createComposableAnnotatingSampler, +} from '../src'; + +describe('ComposableAnnotatingSampler', () => { + const alwaysOnSampler = createComposableAlwaysOnSampler(); + + it('should have a description', () => { + const sampler = createComposableAnnotatingSampler(alwaysOnSampler, {}); + assert.strictEqual( + sampler.toString(), + 'ComposableAnnotatingSampler(ComposableAlwaysOnSampler, attributes)' + ); + }); + + it('should add attributes to SamplingIntent', () => { + const delegateIntent = alwaysOnSampler.getSamplingIntent( + context.active(), + 'unused', + 'span-name', + SpanKind.SERVER, + {}, + [] + ); + const sampler = createComposableAnnotatingSampler(alwaysOnSampler, { + foo: 'bar', + }); + const intent = sampler.getSamplingIntent( + context.active(), + 'unused', + 'span-name', + SpanKind.SERVER, + {}, + [] + ); + assert.strictEqual(intent.threshold, delegateIntent.threshold); + assert.strictEqual( + intent.thresholdReliable, + delegateIntent.thresholdReliable + ); + assert.deepStrictEqual(intent.attributes, { foo: 'bar' }); + }); + + it('should merge attributes', () => { + const sampler = createComposableAnnotatingSampler( + createComposableAnnotatingSampler(alwaysOnSampler, { + foo: 'baz', + wuz: 'here', + }), + { foo: 'bar', spam: 'eggs' } + ); + const intent = sampler.getSamplingIntent( + context.active(), + 'unused', + 'span-name', + SpanKind.SERVER, + {}, + [] + ); + assert.deepStrictEqual(intent.attributes, { + foo: 'bar', // outer annotating sampler wins + spam: 'eggs', + wuz: 'here', + }); + }); +}); diff --git a/experimental/packages/sampler-composite/test/rulebased.test.ts b/experimental/packages/sampler-composite/test/rulebased.test.ts new file mode 100644 index 00000000000..99fe5c3649f --- /dev/null +++ b/experimental/packages/sampler-composite/test/rulebased.test.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 * as assert from 'assert'; + +import type { Attributes, Context } from '@opentelemetry/api'; +import { context, SpanKind } from '@opentelemetry/api'; + +import { + createComposableAlwaysOffSampler, + createComposableAlwaysOnSampler, + createComposableAnnotatingSampler, + createComposableRuleBasedSampler, +} from '../src'; +import type { SamplingPredicate } from '../src/types'; +import { INVALID_THRESHOLD } from '../src/util'; + +describe('ComposableRuleBasedSampler', () => { + // Building a test sampler something like the example given in the spec: + // https://opentelemetry.io/docs/specs/otel/trace/sdk/#composablerulebased + const alwaysOn = createComposableAlwaysOnSampler(); + const alwaysOff = createComposableAlwaysOffSampler(); + const isHealthCheck: SamplingPredicate = ( + _context, + _traceId, + _spanName, + _spanKind, + attributes: Attributes, + _links + ) => { + return attributes['http.route'] === '/healthcheck'; + }; + function isCheckout( + _context: Context, + _traceId: string, + _spanName: string, + _spanKind: SpanKind, + attributes: Attributes + ) { + return attributes['http.route'] === '/checkout'; + } + const sampler = createComposableRuleBasedSampler([ + [isHealthCheck, alwaysOff], + [ + isCheckout, + createComposableAnnotatingSampler(alwaysOn, { is: 'checkout' }), + ], + [ + () => true, + createComposableAnnotatingSampler(alwaysOn, { is: 'catchall' }), + ], + ]); + + it('should have a description', () => { + assert.strictEqual( + sampler.toString(), + 'ComposableRuleBasedSampler([' + + '(isHealthCheck, ComposableAlwaysOffSampler), ' + + '(isCheckout, ComposableAnnotatingSampler(ComposableAlwaysOnSampler, attributes)), ' + + '((anonymous), ComposableAnnotatingSampler(ComposableAlwaysOnSampler, attributes))' + + '])' + ); + }); + + it('should use the matched samplers', () => { + let intent; + + intent = sampler.getSamplingIntent( + context.active(), + 'unused-trace-id', + 'span-name', + SpanKind.SERVER, + { 'http.route': '/healthcheck' }, + [] + ); + assert.strictEqual(intent.threshold, -1n); // used the alwaysOff sampler + assert.strictEqual(intent.attributes, undefined); + + intent = sampler.getSamplingIntent( + context.active(), + 'unused-trace-id', + 'span-name', + SpanKind.SERVER, + { 'http.route': '/checkout' }, + [] + ); + assert.strictEqual(intent.threshold, 0n); // used the alwaysOn sampler + assert.deepStrictEqual(intent.attributes, { is: 'checkout' }); + + intent = sampler.getSamplingIntent( + context.active(), + 'unused-trace-id', + 'span-name', + SpanKind.SERVER, + { 'http.route': '/another' }, + [] + ); + assert.strictEqual(intent.threshold, 0n); // used the alwaysOn sampler + assert.deepStrictEqual(intent.attributes, { is: 'catchall' }); + }); + + it('should fallback to a non-sampling intent', () => { + const sampler2 = createComposableRuleBasedSampler([]); + const intent = sampler2.getSamplingIntent( + context.active(), + 'unused-trace-id', + 'span-name', + SpanKind.SERVER, + {}, + [] + ); + assert.strictEqual(intent.threshold, INVALID_THRESHOLD); + assert.strictEqual(intent.thresholdReliable, false); + }); +});