diff --git a/smoke-tests/smoke-e2e.bats b/smoke-tests/smoke-e2e.bats index a9d2a200..fd8b72f5 100644 --- a/smoke-tests/smoke-e2e.bats +++ b/smoke-tests/smoke-e2e.bats @@ -59,3 +59,10 @@ teardown_file() { result=$(span_attributes_for ${DOCUMENT_LOAD_SCOPE} | jq "select(.key == \"session.id\").value.stringValue") assert_not_empty "$result" } + +@test "Agent includes SampleRate key on all spans" { + result=$(span_attributes_for ${DOCUMENT_LOAD_SCOPE} | jq "select(.key == \"SampleRate\").value.intValue") + assert_equal "$result" '"1" +"1" +"1"' +} diff --git a/src/deterministic-sampler.ts b/src/deterministic-sampler.ts new file mode 100644 index 00000000..41ae08d7 --- /dev/null +++ b/src/deterministic-sampler.ts @@ -0,0 +1,72 @@ +import { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'; +import { + AlwaysOnSampler, + Sampler, + SamplingResult, + TraceIdRatioBasedSampler, +} from '@opentelemetry/sdk-trace-base'; +import { getSampleRate } from './util'; +import { HoneycombOptions } from './types'; + +/** + * Builds and returns a Deterministic Sampler that uses the provided sample rate to + * configure the inner sampler. + * @param options The {@link HoneycombOptions} + * @returns a {@link DeterministicSampler} + */ +export const configureDeterministicSampler = (options?: HoneycombOptions) => { + const sampleRate = getSampleRate(options); + return new DeterministicSampler(sampleRate); +}; + +/** + * A {@link Sampler} that uses a deterministic sample rate to configure the sampler. + */ +export class DeterministicSampler implements Sampler { + private _sampleRate: number; + private _sampler: Sampler; + + constructor(sampleRate: number) { + this._sampleRate = sampleRate; + switch (sampleRate) { + // sample rate of 1 is default, send everything + case 1: + this._sampler = new AlwaysOnSampler(); + break; + default: { + const ratio = 1.0 / sampleRate; + this._sampler = new TraceIdRatioBasedSampler(ratio); + break; + } + } + } + + shouldSample( + context: Context, + traceId: string, + spanName: string, + spanKind: SpanKind, + attributes: Attributes, + links: Link[], + ): SamplingResult { + const result = this._sampler.shouldSample( + context, + traceId, + spanName, + spanKind, + attributes, + links, + ); + return { + ...result, + attributes: { + ...result.attributes, + SampleRate: this._sampleRate, + }, + }; + } + + toString(): string { + return `DeterministicSampler(${this._sampler.toString()})`; + } +} diff --git a/src/honeycomb-debug.ts b/src/honeycomb-debug.ts index 27db661c..26d3d828 100644 --- a/src/honeycomb-debug.ts +++ b/src/honeycomb-debug.ts @@ -2,6 +2,7 @@ import { HoneycombOptions } from './types'; import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'; import { defaultOptions, + getSampleRate, getTracesApiKey, getTracesEndpoint, MISSING_API_KEY_ERROR, @@ -33,6 +34,7 @@ export function configureDebug(options?: HoneycombOptions): void { debugTracesApiKey(currentOptions); debugServiceName(currentOptions); debugTracesEndpoint(currentOptions); + debugSampleRate(currentOptions); } function debugTracesApiKey(options: HoneycombOptions): void { @@ -67,3 +69,15 @@ function debugTracesEndpoint(options: HoneycombOptions): void { `@honeycombio/opentelemetry-web: Endpoint configured for traces: '${tracesEndpoint}'`, ); } + +function debugSampleRate(options: HoneycombOptions): void { + const sampleRate = getSampleRate(options); + if (!sampleRate) { + // this should never happen, but guard just in case? + diag.debug('No sampler configured for traces'); + return; + } + diag.debug( + `@honeycombio/opentelemetry-web: Sample Rate configured for traces: '${sampleRate}'`, + ); +} diff --git a/src/honeycomb-otel-sdk.ts b/src/honeycomb-otel-sdk.ts index 959cae5e..ea585ca5 100644 --- a/src/honeycomb-otel-sdk.ts +++ b/src/honeycomb-otel-sdk.ts @@ -6,6 +6,7 @@ import { configureBrowserAttributesResource } from './browser-attributes-resourc import { mergeResources } from './merge-resources'; import { configureDebug } from './honeycomb-debug'; import { configureSpanProcessors } from './span-processor-builder'; +import { configureDeterministicSampler } from './deterministic-sampler'; export class HoneycombWebSDK extends WebSDK { constructor(options?: HoneycombOptions) { @@ -17,6 +18,7 @@ export class HoneycombWebSDK extends WebSDK { options?.resource, configureHoneycombResource(), ]), + sampler: configureDeterministicSampler(options), // Exporter is configured through the span processor because // the base SDK does not allow having both a spanProcessor and a // traceExporter configured at the same time. diff --git a/src/types.ts b/src/types.ts index 8f1c6378..f8fcbadb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -71,11 +71,11 @@ export interface HoneycombOptions extends Partial { */ serviceName?: string; - /** The sample rate used to determine whether a trace is exported. Defaults to 1 (send everything). - * If you want to send a random fraction of traces, make this a whole number greater than 1. Only 1 in `sampleRate` traces will be sent. - * TODO: Not yet implemented + /** The sample rate used to determine whether a trace is exported. + * This must be a whole number greater than 1. Only 1 out of every `sampleRate` traces will be randomly selected to be sent. + * Defaults to 1 (send everything). */ - // sampleRate?: number; + sampleRate?: number; /** The debug flag enables additional logging that us useful when debugging your application. Do not use in production. * Defaults to 'false'. diff --git a/src/util.ts b/src/util.ts index 04c018f8..b3db5072 100644 --- a/src/util.ts +++ b/src/util.ts @@ -5,6 +5,7 @@ export const DEFAULT_API_ENDPOINT = 'https://api.honeycomb.io'; export const TRACES_PATH = 'v1/traces'; export const DEFAULT_TRACES_ENDPOINT = `${DEFAULT_API_ENDPOINT}/${TRACES_PATH}`; export const DEFAULT_SERVICE_NAME = 'unknown_service'; +export const DEFAULT_SAMPLE_RATE = 1; /** * Default options for the Honeycomb Web SDK. @@ -16,8 +17,8 @@ export const defaultOptions: HoneycombOptions = { tracesEndpoint: DEFAULT_TRACES_ENDPOINT, serviceName: DEFAULT_SERVICE_NAME, debug: false, + sampleRate: 1, // TODO: Not yet implemented - // sampleRate: 1, // localVisualizations: false, // skipOptionsValidation: false, }; @@ -67,3 +68,16 @@ export const getTracesEndpoint = (options?: HoneycombOptions) => { export const getTracesApiKey = (options?: HoneycombOptions) => { return options?.tracesApiKey || options?.apiKey; }; + +export const getSampleRate = (options?: HoneycombOptions) => { + if ( + // sample rate must be a whole integer greater than 0 + options?.sampleRate && + options?.sampleRate > 0 && + Number.isSafeInteger(options?.sampleRate) + ) { + return options?.sampleRate; + } + + return DEFAULT_SAMPLE_RATE; +}; diff --git a/test/deterministic-sampler.test.ts b/test/deterministic-sampler.test.ts new file mode 100644 index 00000000..b3f4cb7c --- /dev/null +++ b/test/deterministic-sampler.test.ts @@ -0,0 +1,108 @@ +import { + configureDeterministicSampler, + DeterministicSampler, +} from '../src/deterministic-sampler'; +import { ROOT_CONTEXT, SpanKind, trace, TraceFlags } from '@opentelemetry/api'; +import { + SamplingDecision, + SamplingResult, +} from '@opentelemetry/sdk-trace-base'; + +const traceId = 'd4cda95b652f4a1592b449d5929fda1b'; +const spanId = '6e0c63257de34c92'; +const spanName = 'doStuff'; + +const getSamplingResult = (sampler: DeterministicSampler): SamplingResult => { + return sampler.shouldSample( + trace.setSpanContext(ROOT_CONTEXT, { + traceId, + spanId, + traceFlags: TraceFlags.NONE, + }), + traceId, + spanName, + SpanKind.CLIENT, + {}, + [], + ); +}; + +describe('deterministic sampler', () => { + test('sampler with rate of 1 configures inner AlwaysOnSampler', () => { + const sampler = new DeterministicSampler(1); + expect(sampler).toBeInstanceOf(DeterministicSampler); + expect(sampler.toString()).toBe('DeterministicSampler(AlwaysOnSampler)'); + + const result = getSamplingResult(sampler); + expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(result.attributes).toEqual({ SampleRate: 1 }); + }); + + test('sampler with rate of 10 configures inner TraceIdRatioBased sampler with a ratio of 0.1', () => { + const sampler = new DeterministicSampler(10); + expect(sampler).toBeInstanceOf(DeterministicSampler); + expect(sampler.toString()).toBe( + 'DeterministicSampler(TraceIdRatioBased{0.1})', + ); + + const result = getSamplingResult(sampler); + expect(result.decision).toBe(SamplingDecision.NOT_RECORD); + expect(result.attributes).toEqual({ SampleRate: 10 }); + }); +}); + +describe('configureDeterministicSampler', () => { + test('sample rate of 1 configures inner AlwaysOnSampler', () => { + const options = { + sampleRate: 1, + }; + const sampler = configureDeterministicSampler(options); + expect(sampler).toBeInstanceOf(DeterministicSampler); + expect(sampler.toString()).toBe('DeterministicSampler(AlwaysOnSampler)'); + + const result = getSamplingResult(sampler); + expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(result.attributes).toEqual({ SampleRate: 1 }); + }); + + test('sample rate of 0 configures inner AlwaysOnSampler', () => { + const options = { + sampleRate: 0, + }; + const sampler = configureDeterministicSampler(options); + expect(sampler).toBeInstanceOf(DeterministicSampler); + expect(sampler.toString()).toBe('DeterministicSampler(AlwaysOnSampler)'); + + const result = getSamplingResult(sampler); + expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(result.attributes).toEqual({ SampleRate: 1 }); + }); + + test('sample rate of -42 configures inner AlwaysOn Sampler', () => { + const options = { + sampleRate: 0, + }; + const sampler = configureDeterministicSampler(options); + expect(sampler).toBeInstanceOf(DeterministicSampler); + expect(sampler.toString()).toBe('DeterministicSampler(AlwaysOnSampler)'); + + const result = getSamplingResult(sampler); + expect(result.decision).toBe(SamplingDecision.RECORD_AND_SAMPLED); + expect(result.attributes).toEqual({ SampleRate: 1 }); + }); + + test('sample rate of 10 configures inner TraceIdRatioBased sampler with a ratio of 0.1', () => { + const options = { + sampleRate: 10, + }; + const sampler = configureDeterministicSampler(options); + expect(sampler).toBeInstanceOf(DeterministicSampler); + expect(sampler.toString()).toBe( + 'DeterministicSampler(TraceIdRatioBased{0.1})', + ); + + const result = getSamplingResult(sampler); + expect(result.decision).toBe(SamplingDecision.NOT_RECORD); + expect(result.attributes).toEqual({ SampleRate: 10 }); + }); +}); diff --git a/test/honeycomb-debug.test.ts b/test/honeycomb-debug.test.ts index b0a6e436..461b36bf 100644 --- a/test/honeycomb-debug.test.ts +++ b/test/honeycomb-debug.test.ts @@ -32,6 +32,9 @@ describe('when debug is set to true', () => { expect(consoleSpy.mock.calls[4][0]).toContain( `@honeycombio/opentelemetry-web: Endpoint configured for traces: '${defaultOptions.tracesEndpoint}'`, ); + expect(consoleSpy.mock.calls[5][0]).toContain( + `@honeycombio/opentelemetry-web: Sample Rate configured for traces: '${defaultOptions.sampleRate}'`, + ); }); }); describe('when options are provided', () => { @@ -41,6 +44,7 @@ describe('when debug is set to true', () => { endpoint: 'http://shenanigans:1234', apiKey: 'my-key', serviceName: 'my-service', + sampleRate: 2, }; new HoneycombWebSDK(testConfig); expect(consoleSpy.mock.calls[1][0]).toContain( @@ -55,6 +59,9 @@ describe('when debug is set to true', () => { expect(consoleSpy.mock.calls[4][0]).toContain( `@honeycombio/opentelemetry-web: Endpoint configured for traces: '${testConfig.endpoint}/${TRACES_PATH}'`, ); + expect(consoleSpy.mock.calls[5][0]).toContain( + `@honeycombio/opentelemetry-web: Sample Rate configured for traces: '${testConfig.sampleRate}'`, + ); }); }); }); diff --git a/test/util.test.ts b/test/util.test.ts index be61901c..55bd578b 100644 --- a/test/util.test.ts +++ b/test/util.test.ts @@ -1,4 +1,5 @@ import { + getSampleRate, getTracesApiKey, getTracesEndpoint, isClassic, @@ -100,3 +101,38 @@ describe('traces api key', () => { expect(getTracesApiKey(options)).toBe('traces-api-key'); }); }); + +describe('sample rate', () => { + it('should default to 1', () => { + const options = {}; + expect(getSampleRate(options)).toBe(1); + }); + + it('should use provided sample rate if valid', () => { + const options = { + sampleRate: 2, + }; + expect(getSampleRate(options)).toBe(2); + }); + + it('should use default sample rate if provided with 0', () => { + const options = { + sampleRate: 0, + }; + expect(getSampleRate(options)).toBe(1); + }); + + it('should use default sample rate if provided with negative', () => { + const options = { + sampleRate: -42, + }; + expect(getSampleRate(options)).toBe(1); + }); + + it('should use default sample rate if provided with float', () => { + const options = { + sampleRate: 3.14, + }; + expect(getSampleRate(options)).toBe(1); + }); +});