Skip to content

Commit c2e446e

Browse files
mydeacadesalaberry
authored andcommitted
feat(core): Handle string sample rates (getsentry#11305)
If users pass e.g. `sampleRate: process.env.SAMPLE_RATE`, this will be somewhat silently ignored because we ignore non-number sample rates. This is a bit of a footgun. So this PR changes this so that we actually accept such sample rates. In the process, I also removed the `isNaN` polyfill as this is not really needed anymore. I also updated OTEL to use the same sampling function as core (to avoid this drifting apart), and am using this for `sampleRate`, `tracesSampleRate` and the replay sample rates now. See e.g. getsentry#11262
1 parent 7cf9d6e commit c2e446e

File tree

19 files changed

+184
-182
lines changed

19 files changed

+184
-182
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
window._errorCount = 0;
6+
7+
Sentry.init({
8+
dsn: 'https://[email protected]/1337',
9+
sampleRate: '0',
10+
beforeSend() {
11+
window._errorCount++;
12+
return null;
13+
},
14+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Sentry.captureException(new Error('test error'));
2+
3+
window._testDone = true;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
5+
sentryTest('parses a string sample rate', async ({ getLocalTestUrl, page }) => {
6+
const url = await getLocalTestUrl({ testDir: __dirname });
7+
8+
await page.goto(url);
9+
10+
await page.waitForFunction('window._testDone');
11+
await page.evaluate('window.Sentry.getClient().flush()');
12+
13+
const count = await page.evaluate('window._errorCount');
14+
15+
expect(count).toStrictEqual(0);
16+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://[email protected]/1337',
7+
tracesSampleRate: '1',
8+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Sentry.startSpan({ name: 'test span' }, () => {
2+
// noop
3+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { envelopeRequestParser, shouldSkipTracingTest, waitForTransactionRequestOnUrl } from '../../../utils/helpers';
5+
6+
sentryTest('parses a string sample rate', async ({ getLocalTestUrl, page }) => {
7+
if (shouldSkipTracingTest()) {
8+
sentryTest.skip();
9+
}
10+
11+
const url = await getLocalTestUrl({ testDir: __dirname });
12+
13+
const req = await waitForTransactionRequestOnUrl(page, url);
14+
const eventData = envelopeRequestParser(req);
15+
16+
expect(eventData.contexts?.trace?.data?.['sentry.sample_rate']).toStrictEqual(1);
17+
});

packages/core/src/baseclient.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ import { setupIntegration, setupIntegrations } from './integration';
5353
import type { Scope } from './scope';
5454
import { updateSession } from './session';
5555
import { getDynamicSamplingContextFromClient } from './tracing/dynamicSamplingContext';
56+
import { parseSampleRate } from './utils/parseSampleRate';
5657
import { prepareEvent } from './utils/prepareEvent';
5758

5859
const ALREADY_SEEN_ERROR = "Not capturing exception because it's already been captured.";
@@ -702,7 +703,8 @@ export abstract class BaseClient<O extends ClientOptions> implements Client<O> {
702703
// 1.0 === 100% events are sent
703704
// 0.0 === 0% events are sent
704705
// Sampling for transaction happens somewhere else
705-
if (isError && typeof sampleRate === 'number' && Math.random() > sampleRate) {
706+
const parsedSampleRate = typeof sampleRate === 'undefined' ? undefined : parseSampleRate(sampleRate);
707+
if (isError && typeof parsedSampleRate === 'number' && Math.random() > parsedSampleRate) {
706708
this.recordDroppedEvent('sample_rate', 'error', event);
707709
return rejectedSyncPromise(
708710
new SentryError(

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export {
8686
getActiveSpan,
8787
addChildSpanToSpan,
8888
} from './utils/spanUtils';
89+
export { parseSampleRate } from './utils/parseSampleRate';
8990
export { applySdkMetadata } from './utils/sdkMetadata';
9091
export { DEFAULT_ENVIRONMENT } from './constants';
9192
export { addBreadcrumb } from './breadcrumbs';

packages/core/src/tracing/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,4 @@ export {
1717
} from './trace';
1818
export { getDynamicSamplingContextFromClient, getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
1919
export { setMeasurement } from './measurement';
20+
export { sampleSpan } from './sampling';

packages/core/src/tracing/sampling.ts

Lines changed: 16 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1-
import type { Options, SamplingContext, TransactionArguments } from '@sentry/types';
2-
import { isNaN, logger } from '@sentry/utils';
1+
import type { Options, SamplingContext } from '@sentry/types';
2+
import { logger } from '@sentry/utils';
33

44
import { DEBUG_BUILD } from '../debug-build';
55
import { hasTracingEnabled } from '../utils/hasTracingEnabled';
6+
import { parseSampleRate } from '../utils/parseSampleRate';
67

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

26-
const transactionContextSampled = transactionContext.sampled;
27-
// if the user has forced a sampling decision by passing a `sampled` value in
28-
// their transaction context, go with that.
29-
if (transactionContextSampled !== undefined) {
30-
return [transactionContextSampled, Number(transactionContextSampled)];
31-
}
32-
3323
// we would have bailed already if neither `tracesSampler` nor `tracesSampleRate` nor `enableTracing` were defined, so one of these should
3424
// work; prefer the hook if so
3525
let sampleRate;
@@ -44,15 +34,17 @@ export function sampleTransaction(
4434
sampleRate = 1;
4535
}
4636

47-
// Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The
48-
// only valid values are booleans or numbers between 0 and 1.)
49-
if (!isValidSampleRate(sampleRate)) {
37+
// Since this is coming from the user (or from a function provided by the user), who knows what we might get.
38+
// (The only valid values are booleans or numbers between 0 and 1.)
39+
const parsedSampleRate = parseSampleRate(sampleRate);
40+
41+
if (parsedSampleRate === undefined) {
5042
DEBUG_BUILD && logger.warn('[Tracing] Discarding transaction because of invalid sample rate.');
5143
return [false];
5244
}
5345

5446
// if the function returned 0 (or false), or if `tracesSampleRate` is 0, it's a sign the transaction should be dropped
55-
if (!sampleRate) {
47+
if (!parsedSampleRate) {
5648
DEBUG_BUILD &&
5749
logger.log(
5850
`[Tracing] Discarding transaction because ${
@@ -61,12 +53,12 @@ export function sampleTransaction(
6153
: 'a negative sampling decision was inherited or tracesSampleRate is set to 0'
6254
}`,
6355
);
64-
return [false, Number(sampleRate)];
56+
return [false, parsedSampleRate];
6557
}
6658

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

7163
// if we're not going to keep it, we're done
7264
if (!shouldSample) {
@@ -76,32 +68,8 @@ export function sampleTransaction(
7668
sampleRate,
7769
)})`,
7870
);
79-
return [false, Number(sampleRate)];
80-
}
81-
82-
return [true, Number(sampleRate)];
83-
}
84-
85-
/**
86-
* Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1).
87-
*/
88-
function isValidSampleRate(rate: unknown): rate is number | boolean {
89-
// we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck
90-
if (isNaN(rate) || !(typeof rate === 'number' || typeof rate === 'boolean')) {
91-
DEBUG_BUILD &&
92-
logger.warn(
93-
`[Tracing] Given sample rate is invalid. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify(
94-
rate,
95-
)} of type ${JSON.stringify(typeof rate)}.`,
96-
);
97-
return false;
71+
return [false, parsedSampleRate];
9872
}
9973

100-
// in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false
101-
if (rate < 0 || rate > 1) {
102-
DEBUG_BUILD &&
103-
logger.warn(`[Tracing] Given sample rate is invalid. Sample rate must be between 0 and 1. Got ${rate}.`);
104-
return false;
105-
}
106-
return true;
74+
return [true, parsedSampleRate];
10775
}

packages/core/src/tracing/trace.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
spanToJSON,
1919
} from '../utils/spanUtils';
2020
import { getDynamicSamplingContextFromSpan } from './dynamicSamplingContext';
21-
import { sampleTransaction } from './sampling';
21+
import { sampleSpan } from './sampling';
2222
import { SentryNonRecordingSpan } from './sentryNonRecordingSpan';
2323
import type { SentrySpan } from './sentrySpan';
2424
import { SPAN_STATUS_ERROR } from './spanstatus';
@@ -301,7 +301,7 @@ function _startTransaction(transactionContext: TransactionArguments): Transactio
301301
const client = getClient();
302302
const options: Partial<ClientOptions> = (client && client.getOptions()) || {};
303303

304-
const [sampled, sampleRate] = sampleTransaction(transactionContext, options, {
304+
const [sampled, sampleRate] = sampleSpan(options, {
305305
name: transactionContext.name,
306306
parentSampled: transactionContext.parentSampled,
307307
transactionContext,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { logger } from '@sentry/utils';
2+
import { DEBUG_BUILD } from '../debug-build';
3+
4+
/**
5+
* Parse a sample rate from a given value.
6+
* This will either return a boolean or number sample rate, if the sample rate is valid (between 0 and 1).
7+
* If a string is passed, we try to convert it to a number.
8+
*
9+
* Any invalid sample rate will return `undefined`.
10+
*/
11+
export function parseSampleRate(sampleRate: unknown): number | undefined {
12+
if (typeof sampleRate === 'boolean') {
13+
return Number(sampleRate);
14+
}
15+
16+
const rate = typeof sampleRate === 'string' ? parseFloat(sampleRate) : sampleRate;
17+
if (typeof rate !== 'number' || isNaN(rate)) {
18+
DEBUG_BUILD &&
19+
logger.warn(
20+
`[Tracing] Given sample rate is invalid. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify(
21+
sampleRate,
22+
)} of type ${JSON.stringify(typeof sampleRate)}.`,
23+
);
24+
return undefined;
25+
}
26+
27+
if (rate < 0 || rate > 1) {
28+
DEBUG_BUILD &&
29+
logger.warn(`[Tracing] Given sample rate is invalid. Sample rate must be between 0 and 1. Got ${rate}.`);
30+
return undefined;
31+
}
32+
33+
return rate;
34+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { parseSampleRate } from '../../../src/utils/parseSampleRate';
2+
3+
describe('parseSampleRate', () => {
4+
it.each([
5+
[undefined, undefined],
6+
[null, undefined],
7+
[0, 0],
8+
[1, 1],
9+
[0.555, 0.555],
10+
[2, undefined],
11+
[false, 0],
12+
[true, 1],
13+
['', undefined],
14+
['aha', undefined],
15+
['1', 1],
16+
['1.5', undefined],
17+
['0.555', 0.555],
18+
['0', 0],
19+
])('works with %p', (input, sampleRate) => {
20+
const actual = parseSampleRate(input);
21+
expect(actual).toBe(sampleRate);
22+
});
23+
});

0 commit comments

Comments
 (0)