Skip to content

feat(core): Handle string sample rates #11305

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Mar 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

window._errorCount = 0;

Sentry.init({
dsn: 'https://[email protected]/1337',
sampleRate: '0',
beforeSend() {
window._errorCount++;
return null;
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Sentry.captureException(new Error('test error'));

window._testDone = true;
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../../utils/fixtures';

sentryTest('parses a string sample rate', async ({ getLocalTestUrl, page }) => {
const url = await getLocalTestUrl({ testDir: __dirname });

await page.goto(url);

await page.waitForFunction('window._testDone');
await page.evaluate('window.Sentry.getClient().flush()');

const count = await page.evaluate('window._errorCount');

expect(count).toStrictEqual(0);
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://[email protected]/1337',
tracesSampleRate: '1',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Sentry.startSpan({ name: 'test span' }, () => {
// noop
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect } from '@playwright/test';

import { sentryTest } from '../../../utils/fixtures';
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequestOnUrl } from '../../../utils/helpers';

sentryTest('parses a string sample rate', async ({ getLocalTestUrl, page }) => {
if (shouldSkipTracingTest()) {
sentryTest.skip();
}

const url = await getLocalTestUrl({ testDir: __dirname });

const req = await waitForTransactionRequestOnUrl(page, url);
const eventData = envelopeRequestParser(req);

expect(eventData.contexts?.trace?.data?.['sentry.sample_rate']).toStrictEqual(1);
});
4 changes: 3 additions & 1 deletion packages/core/src/baseclient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import { setupIntegration, setupIntegrations } from './integration';
import type { Scope } from './scope';
import { updateSession } from './session';
import { getDynamicSamplingContextFromClient } from './tracing/dynamicSamplingContext';
import { parseSampleRate } from './utils/parseSampleRate';
import { prepareEvent } from './utils/prepareEvent';

const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
Expand Down Expand Up @@ -702,7 +703,8 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
// 1.0 === 100% events are sent
// 0.0 === 0% events are sent
// Sampling for transaction happens somewhere else
if (isError && typeof sampleRate === 'number' && Math.random() > sampleRate) {
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
if (isError && typeof parsedSampleRate === 'number' && Math.random() > parsedSampleRate) {
this.recordDroppedEvent('sample_rate', 'error', event);
return rejectedSyncPromise(
new SentryError(
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export {
getActiveSpan,
addChildSpanToSpan,
} from './utils/spanUtils';
export { parseSampleRate } from './utils/parseSampleRate';
export { applySdkMetadata } from './utils/sdkMetadata';
export { DEFAULT_ENVIRONMENT } from './constants';
export { addBreadcrumb } from './breadcrumbs';
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/tracing/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ export {
} from './trace';
export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
export { setMeasurement } from './measurement';
export { sampleSpan } from './sampling';
64 changes: 16 additions & 48 deletions packages/core/src/tracing/sampling.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import type { Options, SamplingContext, TransactionArguments } from '@sentry/types';
import { isNaN, logger } from '@sentry/utils';
import type { Options, SamplingContext } from '@sentry/types';
import { logger } from '@sentry/utils';

import { DEBUG_BUILD } from '../debug-build';
import { hasTracingEnabled } from '../utils/hasTracingEnabled';
import { parseSampleRate } from '../utils/parseSampleRate';

/**
* Makes a sampling decision for the given transaction and stores it on the transaction.
* Makes a sampling decision for the given options.
*
* Called every time a transaction is created. Only transactions which emerge with a `sampled` value of `true` will be
* Called every time a root span is created. Only root spans which emerge with a `sampled` value of `true` will be
* sent to Sentry.
*
* This method muttes the given `transaction` and will set the `sampled` value on it.
* It returns the same transaction, for convenience.
*/
export function sampleTransaction(
transactionContext: TransactionArguments,
export function sampleSpan(
options: Pick<Options, 'tracesSampleRate' | 'tracesSampler' | 'enableTracing'>,
samplingContext: SamplingContext,
): [sampled: boolean, sampleRate?: number] {
Expand All @@ -23,13 +20,6 @@ export function sampleTransaction(
return [false];
}

const transactionContextSampled = transactionContext.sampled;
// if the user has forced a sampling decision by passing a `sampled` value in
// their transaction context, go with that.
if (transactionContextSampled !== undefined) {
return [transactionContextSampled, Number(transactionContextSampled)];
}

// we would have bailed already if neither `tracesSampler` nor `tracesSampleRate` nor `enableTracing` were defined, so one of these should
// work; prefer the hook if so
let sampleRate;
Expand All @@ -44,15 +34,17 @@ export function sampleTransaction(
sampleRate = 1;
}

// Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The
// only valid values are booleans or numbers between 0 and 1.)
if (!isValidSampleRate(sampleRate)) {
// Since this is coming from the user (or from a function provided by the user), who knows what we might get.
// (The only valid values are booleans or numbers between 0 and 1.)
const parsedSampleRate = parseSampleRate(sampleRate);

if (parsedSampleRate === undefined) {
DEBUG_BUILD && logger.warn('[Tracing] Discarding transaction because of invalid sample rate.');
return [false];
}

// if the function returned 0 (or false), or if `tracesSampleRate` is 0, it's a sign the transaction should be dropped
if (!sampleRate) {
if (!parsedSampleRate) {
DEBUG_BUILD &&
logger.log(
`[Tracing] Discarding transaction because ${
Expand All @@ -61,12 +53,12 @@ export function sampleTransaction(
: 'a negative sampling decision was inherited or tracesSampleRate is set to 0'
}`,
);
return [false, Number(sampleRate)];
return [false, parsedSampleRate];
}

// Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is
// a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false.
const shouldSample = Math.random() < sampleRate;
const shouldSample = Math.random() < parsedSampleRate;

// if we're not going to keep it, we're done
if (!shouldSample) {
Expand All @@ -76,32 +68,8 @@ export function sampleTransaction(
sampleRate,
)})`,
);
return [false, Number(sampleRate)];
}

return [true, Number(sampleRate)];
}

/**
* Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1).
*/
function isValidSampleRate(rate: unknown): rate is number | boolean {
// we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck
if (isNaN(rate) || !(typeof rate === 'number' || typeof rate === 'boolean')) {
DEBUG_BUILD &&
logger.warn(
`[Tracing] Given sample rate is invalid. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify(
rate,
)} of type ${JSON.stringify(typeof rate)}.`,
);
return false;
return [false, parsedSampleRate];
}

// in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false
if (rate < 0 || rate > 1) {
DEBUG_BUILD &&
logger.warn(`[Tracing] Given sample rate is invalid. Sample rate must be between 0 and 1. Got ${rate}.`);
return false;
}
return true;
return [true, parsedSampleRate];
}
4 changes: 2 additions & 2 deletions packages/core/src/tracing/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
spanToJSON,
} from '../utils/spanUtils';
import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
import { sampleTransaction } from './sampling';
import { sampleSpan } from './sampling';
import { SentryNonRecordingSpan } from './sentryNonRecordingSpan';
import type { SentrySpan } from './sentrySpan';
import { SPAN_STATUS_ERROR } from './spanstatus';
Expand Down Expand Up @@ -301,7 +301,7 @@ function _startTransaction(transactionContext: TransactionArguments): Transactio
const client = getClient();
const options: Partial<ClientOptions> = (client && client.getOptions()) || {};

const [sampled, sampleRate] = sampleTransaction(transactionContext, options, {
const [sampled, sampleRate] = sampleSpan(options, {
name: transactionContext.name,
parentSampled: transactionContext.parentSampled,
transactionContext,
Expand Down
34 changes: 34 additions & 0 deletions packages/core/src/utils/parseSampleRate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { logger } from '@sentry/utils';
import { DEBUG_BUILD } from '../debug-build';

/**
* Parse a sample rate from a given value.
* This will either return a boolean or number sample rate, if the sample rate is valid (between 0 and 1).
* If a string is passed, we try to convert it to a number.
*
* Any invalid sample rate will return `undefined`.
*/
export function parseSampleRate(sampleRate: unknown): number | undefined {
if (typeof sampleRate === 'boolean') {
return Number(sampleRate);
}

const rate = typeof sampleRate === 'string' ? parseFloat(sampleRate) : sampleRate;
if (typeof rate !== 'number' || isNaN(rate)) {
DEBUG_BUILD &&
logger.warn(
`[Tracing] Given sample rate is invalid. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify(
sampleRate,
)} of type ${JSON.stringify(typeof sampleRate)}.`,
);
return undefined;
}

if (rate < 0 || rate > 1) {
DEBUG_BUILD &&
logger.warn(`[Tracing] Given sample rate is invalid. Sample rate must be between 0 and 1. Got ${rate}.`);
return undefined;
}

return rate;
}
23 changes: 23 additions & 0 deletions packages/core/test/lib/utils/parseSampleRate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { parseSampleRate } from '../../../src/utils/parseSampleRate';

describe('parseSampleRate', () => {
it.each([
[undefined, undefined],
[null, undefined],
[0, 0],
[1, 1],
[0.555, 0.555],
[2, undefined],
[false, 0],
[true, 1],
['', undefined],
['aha', undefined],
['1', 1],
['1.5', undefined],
['0.555', 0.555],
['0', 0],
])('works with %p', (input, sampleRate) => {
const actual = parseSampleRate(input);
expect(actual).toBe(sampleRate);
});
});
Loading