Skip to content

Commit

Permalink
feat: Add DeterministicSampler for easier sampling (#70)
Browse files Browse the repository at this point in the history
## Which problem is this PR solving?

- Closes #67 

## Short description of the changes

- add Deterministic Sampler
- add sampleRate info to debug
- add unit tests and update smoke test

## How to verify that this has the expected result

```js
  const sdk = new HoneycombWebSDK({
    apiKey: HONEYCOMB_API_KEY,
    serviceName: 'web-distro',
    debug: true,
    sampleRate: 2,
    instrumentations: [getWebAutoInstrumentations()], // add auto-instrumentation
  });
```

With a sampleRate higher than one you may have to generate a few traces
before it shows up in Honeycomb! But with debug enabled at least you can
see the config logged, and also see the "Non recording span" if it's not
going to send because it's being dropped with sampling.

---------

Co-authored-by: Kent Quirk <[email protected]>
  • Loading branch information
JamieDanielson and kentquirk authored Feb 13, 2024
1 parent b0909f8 commit 5318210
Show file tree
Hide file tree
Showing 9 changed files with 265 additions and 5 deletions.
7 changes: 7 additions & 0 deletions smoke-tests/smoke-e2e.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"'
}
72 changes: 72 additions & 0 deletions src/deterministic-sampler.ts
Original file line number Diff line number Diff line change
@@ -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()})`;
}
}
14 changes: 14 additions & 0 deletions src/honeycomb-debug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HoneycombOptions } from './types';
import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api';
import {
defaultOptions,
getSampleRate,
getTracesApiKey,
getTracesEndpoint,
MISSING_API_KEY_ERROR,
Expand Down Expand Up @@ -33,6 +34,7 @@ export function configureDebug(options?: HoneycombOptions): void {
debugTracesApiKey(currentOptions);
debugServiceName(currentOptions);
debugTracesEndpoint(currentOptions);
debugSampleRate(currentOptions);
}

function debugTracesApiKey(options: HoneycombOptions): void {
Expand Down Expand Up @@ -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}'`,
);
}
2 changes: 2 additions & 0 deletions src/honeycomb-otel-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,11 @@ export interface HoneycombOptions extends Partial<WebSDKConfiguration> {
*/
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'.
Expand Down
16 changes: 15 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
};
Expand Down Expand Up @@ -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;
};
108 changes: 108 additions & 0 deletions test/deterministic-sampler.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
7 changes: 7 additions & 0 deletions test/honeycomb-debug.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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(
Expand All @@ -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}'`,
);
});
});
});
36 changes: 36 additions & 0 deletions test/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
getSampleRate,
getTracesApiKey,
getTracesEndpoint,
isClassic,
Expand Down Expand Up @@ -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);
});
});

0 comments on commit 5318210

Please sign in to comment.