Skip to content
This repository was archived by the owner on Sep 17, 2024. It is now read-only.

Commit 8d93776

Browse files
authored
feat(profiling): add profilesSampler (#109)
* feat(profiling): add profilesSampler * ref(utils): remove unnecessar bool check * ref(profiling): disable support for distributed tracing * ref(profiling): remove parentSampled
1 parent 9d7e706 commit 8d93776

File tree

5 files changed

+210
-10
lines changed

5 files changed

+210
-10
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"benchmark:server": "node benchmarks/cpu/benchmark.server.js",
4545
"benchmark:format": "node benchmarks/format/benchmark.format.js",
4646
"benchmark:integration": "node benchmarks/cpu/benchmark.integration.base.js && node benchmarks/cpu/benchmark.integration.disabled.js && node benchmarks/cpu/benchmark.integration.js",
47-
"test:watch": "jest --watch --silent",
47+
"test:watch": "jest --watch",
4848
"test": "jest --config jest.config.ts",
4949
"prettier": "prettier --config ./.prettierrc --write"
5050
},

src/hubextensions.test.ts

Lines changed: 104 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {
33
ClientOptions,
44
Hub,
55
Context,
6+
Client,
67
Transaction,
78
TransactionMetadata
89
} from '@sentry/types';
@@ -36,15 +37,22 @@ function makeTransactionMock(options = {}): Transaction {
3637
} as unknown as Transaction;
3738
}
3839

39-
function makeHubMock({ profilesSampleRate }: { profilesSampleRate: number | undefined }): Hub {
40+
function makeHubMock({
41+
profilesSampleRate,
42+
client
43+
}: {
44+
profilesSampleRate: number | undefined;
45+
client?: Partial<Client<ClientOptions<BaseTransportOptions>>>;
46+
}): Hub {
4047
return {
4148
getClient: jest.fn().mockImplementation(() => {
4249
return {
4350
getOptions: jest.fn().mockImplementation(() => {
4451
return {
4552
profilesSampleRate
4653
} as unknown as ClientOptions<BaseTransportOptions>;
47-
})
54+
}),
55+
...(client ?? {})
4856
};
4957
})
5058
} as unknown as Hub;
@@ -131,4 +139,98 @@ describe('hubextensions', () => {
131139
expect(startProfilingSpy).not.toHaveBeenCalledTimes(1);
132140
expect(stopProfilingSpy).not.toHaveBeenCalledTimes(1);
133141
});
142+
143+
it('disabled if neither profilesSampler and profilesSampleRate are not set', () => {
144+
const hub = makeHubMock({ profilesSampleRate: undefined });
145+
const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock());
146+
147+
const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction);
148+
const samplingContext = { beep: 'boop' };
149+
const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext);
150+
transaction.finish();
151+
152+
const startProfilingSpy = jest.spyOn(profiler, 'startProfiling');
153+
expect(startProfilingSpy).not.toHaveBeenCalled();
154+
});
155+
156+
it('does not call startProfiling if profilesSampler returns invalid rate', () => {
157+
const startProfilingSpy = jest.spyOn(profiler, 'startProfiling');
158+
const options = { profilesSampler: jest.fn().mockReturnValue(NaN) };
159+
const hub = makeHubMock({
160+
profilesSampleRate: undefined,
161+
client: {
162+
// @ts-expect-error mock this
163+
getOptions: () => options
164+
}
165+
});
166+
const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock());
167+
168+
const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction);
169+
const samplingContext = { beep: 'boop' };
170+
const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext);
171+
transaction.finish();
172+
173+
expect(options.profilesSampler).toHaveBeenCalledWith(samplingContext);
174+
expect(startProfilingSpy).not.toHaveBeenCalled();
175+
});
176+
177+
it('does not call startProfiling if profilesSampleRate is invalid', () => {
178+
const startProfilingSpy = jest.spyOn(profiler, 'startProfiling');
179+
const options = { profilesSampler: jest.fn().mockReturnValue(NaN) };
180+
const hub = makeHubMock({
181+
profilesSampleRate: NaN,
182+
client: {
183+
// @ts-expect-error mock this
184+
getOptions: () => options
185+
}
186+
});
187+
const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock());
188+
189+
const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction);
190+
const samplingContext = { beep: 'boop' };
191+
const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext);
192+
transaction.finish();
193+
194+
expect(options.profilesSampler).toHaveBeenCalledWith(samplingContext);
195+
expect(startProfilingSpy).not.toHaveBeenCalled();
196+
});
197+
198+
it('calls profilesSampler with sampling context', () => {
199+
const options = { profilesSampler: jest.fn() };
200+
const hub = makeHubMock({
201+
profilesSampleRate: undefined,
202+
client: {
203+
// @ts-expect-error mock this
204+
getOptions: () => options
205+
}
206+
});
207+
const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock());
208+
209+
const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction);
210+
const samplingContext = { beep: 'boop' };
211+
const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext);
212+
transaction.finish();
213+
214+
expect(options.profilesSampler).toHaveBeenCalledWith(samplingContext);
215+
});
216+
217+
it('prioritizes profilesSampler outcome over profilesSampleRate', () => {
218+
const startProfilingSpy = jest.spyOn(profiler, 'startProfiling');
219+
const options = { profilesSampler: jest.fn().mockReturnValue(1) };
220+
const hub = makeHubMock({
221+
profilesSampleRate: 0,
222+
client: {
223+
// @ts-expect-error mock this
224+
getOptions: () => options
225+
}
226+
});
227+
const startTransaction = jest.fn().mockImplementation(() => makeTransactionMock());
228+
229+
const maybeStartTransactionWithProfiling = __PRIVATE__wrapStartTransactionWithProfiling(startTransaction);
230+
const samplingContext = { beep: 'boop' };
231+
const transaction = maybeStartTransactionWithProfiling.call(hub, { name: '' }, samplingContext);
232+
transaction.finish();
233+
234+
expect(startProfilingSpy).toHaveBeenCalled();
235+
});
134236
});

src/hubextensions.ts

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { logger, uuid4 } from '@sentry/utils';
44

55
import { isDebugBuild } from './env';
66
import { CpuProfilerBindings } from './cpu_profiler';
7+
import { isValidSampleRate } from './utils';
78

89
const MAX_PROFILE_DURATION_MS = 30 * 1000;
910

@@ -30,26 +31,72 @@ export function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: S
3031
const profile_id = uuid4();
3132
const uniqueTransactionName = `${transactionContext.name} ${profile_id}`;
3233

33-
// profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate.
34+
// profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. We dont perform
35+
// the actual multiplication to get the final rate, but we discard the profile if the transaction was sampled,
36+
// so anything after this block from here is based on the transaction sampling.
3437
if (!transaction.sampled) {
3538
return transaction;
3639
}
3740

38-
// @ts-expect-error profilesSampleRate is not part of the options type yet
39-
const profilesSampleRate = this.getClient()?.getOptions?.()?.profilesSampleRate;
40-
if (profilesSampleRate === undefined) {
41+
const client = this.getClient();
42+
if (!client) {
43+
if (isDebugBuild()) {
44+
logger.log('[Profiling] Profiling disabled, no client found.');
45+
}
46+
return transaction;
47+
}
48+
const options = client.getOptions();
49+
if (!options) {
50+
if (isDebugBuild()) {
51+
logger.log('[Profiling] Profiling disabled, no options found.');
52+
}
53+
return transaction;
54+
}
55+
56+
// @ts-expect-error sampler is not yer part of the sdk options
57+
const profilesSampler = options.profilesSampler;
58+
// @ts-expect-error sampler is not yer part of the sdk options
59+
let profilesSampleRate: number | undefined = options.profilesSampleRate;
60+
61+
// Prefer sampler to sample rate if both are provided.
62+
if (typeof profilesSampler === 'function') {
63+
profilesSampleRate = profilesSampler(customSamplingContext);
64+
}
65+
66+
// Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The
67+
// only valid values are booleans or numbers between 0 and 1.)
68+
if (!isValidSampleRate(profilesSampleRate)) {
69+
if (isDebugBuild()) {
70+
logger.warn('[Profiling] Discarding profile because of invalid sample rate.');
71+
}
72+
return transaction;
73+
}
74+
75+
// if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped
76+
if (!profilesSampleRate) {
4177
if (isDebugBuild()) {
4278
logger.log(
43-
'[Profiling] Profiling disabled, enable it by setting `profilesSampleRate` option to SDK init call.'
79+
`[Profiling] Discarding profile because ${
80+
typeof profilesSampler === 'function'
81+
? 'profileSampler returned 0 or false'
82+
: 'a negative sampling decision was inherited or profileSampleRate is set to 0'
83+
}`
4484
);
4585
}
4686
return transaction;
4787
}
4888

89+
// Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is
90+
// a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false.
91+
const sampled = Math.random() < (profilesSampleRate as number | boolean);
4992
// Check if we should sample this profile
50-
if (Math.random() > profilesSampleRate) {
93+
if (!sampled) {
5194
if (isDebugBuild()) {
52-
logger.log('[Profiling] Skip profiling transaction due to sampling.');
95+
logger.log(
96+
`[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number(
97+
profilesSampleRate
98+
)})`
99+
);
53100
}
54101
return transaction;
55102
}

src/utils.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { SdkMetadata, DsnComponents } from '@sentry/types';
22
import type { ProfiledEvent } from './utils';
3+
import { isValidSampleRate } from './utils';
34

45
import {
56
maybeRemoveProfileFromSdkMetadata,
@@ -255,3 +256,27 @@ describe('createProfilingEventEnvelope', () => {
255256
expect(profile.transaction.trace_id).toBe('trace_id');
256257
});
257258
});
259+
260+
describe('isValidSampleRate', () => {
261+
it.each([
262+
[0, true],
263+
[0.1, true],
264+
[1, true],
265+
[true, true],
266+
[false, true],
267+
// invalid values
268+
[1.1, false],
269+
[-0.1, false],
270+
[NaN, false],
271+
[Infinity, false],
272+
[null, false],
273+
[undefined, false],
274+
['', false],
275+
[' ', false],
276+
[{}, false],
277+
[[], false],
278+
[() => null, false]
279+
])('value %s is %s', (input, expected) => {
280+
expect(isValidSampleRate(input)).toBe(expected);
281+
});
282+
});

src/utils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,29 @@ export function getProjectRootDirectory(): string | null {
274274
const components = path.resolve(root_directory).split('/node_modules');
275275
return components?.[0] ?? null;
276276
}
277+
278+
/**
279+
* Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1).
280+
*/
281+
export function isValidSampleRate(rate: unknown): boolean {
282+
// we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck
283+
if ((typeof rate !== 'number' && typeof rate !== 'boolean') || (typeof rate === 'number' && isNaN(rate))) {
284+
if (isDebugBuild()) {
285+
logger.warn(
286+
`[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify(
287+
rate
288+
)} of type ${JSON.stringify(typeof rate)}.`
289+
);
290+
}
291+
return false;
292+
}
293+
294+
// in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false
295+
if (rate < 0 || rate > 1) {
296+
if (isDebugBuild()) {
297+
logger.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`);
298+
}
299+
return false;
300+
}
301+
return true;
302+
}

0 commit comments

Comments
 (0)