Skip to content

Commit c7a44b9

Browse files
committed
make tracesSampler able to return a boolean and make it override sampling inheritance
1 parent 9852af8 commit c7a44b9

File tree

3 files changed

+150
-58
lines changed

3 files changed

+150
-58
lines changed

packages/tracing/src/hubextensions.ts

Lines changed: 59 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,28 @@ function traceHeaders(this: Hub): { [key: string]: string } {
2929
}
3030

3131
/**
32-
* Use sample rate along with a random number generator to make a sampling decision, which all child spans and child
33-
* transactions inherit.
32+
* Implements sampling inheritance and falls back to user-provided static rate if no parent decision is available.
3433
*
35-
* Sample rate is set in SDK config, either as a constant (`tracesSampleRate`) or a function to compute the rate
36-
* (`tracesSampler`).
34+
* @param parentSampled: The parent transaction's sampling decision, if any.
35+
* @param givenRate: The rate to use if no parental decision is available.
36+
*
37+
* @returns The parent's sampling decision (if one exists), or the provided static rate
38+
*/
39+
function _inheritOrUseGivenRate(parentSampled: boolean | undefined, givenRate: unknown): boolean | unknown {
40+
return parentSampled !== undefined ? parentSampled : givenRate;
41+
}
42+
43+
/**
44+
* Makes a sampling decision for the given transaction and stores it on the transaction.
3745
*
3846
* Called every time a transaction is created. Only transactions which emerge with a `sampled` value of `true` will be
3947
* sent to Sentry.
4048
*
41-
* Mutates the given Transaction object and then returns the mutated object.
49+
* @param hub: The hub off of which to read config options
50+
* @param transaction: The transaction needing a sampling decision
51+
* @param sampleContext: Default and user-provided data which may be used to help make the decision
52+
*
53+
* @returns The given transaction with its `sampled` value set
4254
*/
4355
function sample<T extends Transaction>(hub: Hub, transaction: T, sampleContext: SampleContext = {}): T {
4456
const client = hub.getClient();
@@ -50,42 +62,46 @@ function sample<T extends Transaction>(hub: Hub, transaction: T, sampleContext:
5062
return transaction;
5163
}
5264

53-
// we have to test for a pre-existsing sampling decision, in case this transaction is a child transaction and has
54-
// inherited its parent's decision
55-
if (transaction.sampled === undefined) {
56-
// we would have bailed at the beginning if neither `tracesSampler` nor `tracesSampleRate` were defined, so one of
57-
// these should work; prefer the hook if so
58-
const sampleRate =
59-
typeof options.tracesSampler === 'function' ? options.tracesSampler(sampleContext) : options.tracesSampleRate;
60-
61-
// since this is coming from the user, who knows what we might get
62-
if (!isValidSampleRate(sampleRate)) {
63-
logger.warn(`[Tracing] Discarding trace because of invalid sample rate.`);
64-
transaction.sampled = false;
65-
return transaction;
66-
}
65+
// we would have bailed already if neither `tracesSampler` nor `tracesSampleRate` were defined, so one of these should
66+
// work; prefer the hook if so
67+
const sampleRate =
68+
typeof options.tracesSampler === 'function'
69+
? options.tracesSampler(sampleContext)
70+
: _inheritOrUseGivenRate(sampleContext.parentSampled, options.tracesSampleRate);
71+
72+
// Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The
73+
// only valid values are booleans or numbers between 0 and 1.)
74+
if (!isValidSampleRate(sampleRate)) {
75+
logger.warn(`[Tracing] Discarding transaction because of invalid sample rate.`);
76+
transaction.sampled = false;
77+
return transaction;
78+
}
6779

68-
// if the function returned 0, or if the sample rate is set to 0, it's a sign the transaction should be dropped
69-
if (!sampleRate) {
70-
logger.log(
71-
`[Tracing] Discarding trace because ${
72-
typeof options.tracesSampler === 'function' ? 'tracesSampler returned 0' : 'tracesSampleRate is set to 0'
73-
}`,
74-
);
75-
transaction.sampled = false;
76-
return transaction;
77-
}
80+
// if the function returned 0 (or false), or if `tracesSampleRate` is 0, it's a sign the transaction should be dropped
81+
if (!sampleRate) {
82+
logger.log(
83+
`[Tracing] Discarding transaction because ${
84+
typeof options.tracesSampler === 'function'
85+
? 'tracesSampler returned 0 or false'
86+
: 'tracesSampleRate is set to 0'
87+
}`,
88+
);
89+
transaction.sampled = false;
90+
return transaction;
91+
}
7892

79-
// now we roll the dice (Math.random is inclusive of 0, but not of 1, so strict < is safe here)
80-
transaction.sampled = Math.random() < sampleRate;
93+
// Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is
94+
// a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false.
95+
transaction.sampled = Math.random() < (sampleRate as number | boolean);
8196

82-
// if we're not going to keep it, we're done
83-
if (!transaction.sampled) {
84-
logger.log(
85-
`[Tracing] Discarding trace because it's not included in the random sample (sampling rate = ${sampleRate})`,
86-
);
87-
return transaction;
88-
}
97+
// if we're not going to keep it, we're done
98+
if (!transaction.sampled) {
99+
logger.log(
100+
`[Tracing] Discarding transaction because it's not included in the random sample (sampling rate = ${Number(
101+
sampleRate,
102+
)})`,
103+
);
104+
return transaction;
89105
}
90106

91107
// at this point we know we're keeping the transaction, whether because of an inherited decision or because it got
@@ -148,17 +164,19 @@ function getDefaultSampleContext<T extends Transaction>(transaction: T): SampleC
148164
}
149165

150166
/**
151-
* Checks the given sample rate to make sure it is valid (a number between 0 and 1).
167+
* Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1).
152168
*/
153169
function isValidSampleRate(rate: unknown): boolean {
154-
if (!(typeof rate === 'number')) {
170+
if (!(typeof rate === 'number' || typeof rate === 'boolean')) {
155171
logger.warn(
156-
`[Tracing] Given sample rate is invalid. Sample rate must be a number between 0 and 1. Got ${JSON.stringify(
172+
`[Tracing] Given sample rate is invalid. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify(
157173
rate,
158174
)} of type ${JSON.stringify(typeof rate)}.`,
159175
);
160176
return false;
161177
}
178+
179+
// in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false
162180
if (rate < 0 || rate > 1) {
163181
logger.warn(`[Tracing] Given sample rate is invalid. Sample rate must be between 0 and 1. Got ${rate}.`);
164182
return false;

packages/tracing/test/hub.test.ts

Lines changed: 88 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ describe('Hub', () => {
4141
});
4242

4343
describe('transaction sampling', () => {
44-
describe('options', () => {
44+
describe('tracesSampleRate and tracesSampler options', () => {
4545
it("should call tracesSampler if it's defined", () => {
4646
const tracesSampler = jest.fn();
4747
const hub = new Hub(new BrowserClient({ tracesSampler }));
@@ -52,11 +52,20 @@ describe('Hub', () => {
5252

5353
it('should prefer tracesSampler to tracesSampleRate', () => {
5454
const tracesSampler = jest.fn();
55-
const hub = new Hub(new BrowserClient({ tracesSampleRate: 1, tracesSampler: tracesSampler }));
55+
const hub = new Hub(new BrowserClient({ tracesSampleRate: 1, tracesSampler }));
5656
hub.startTransaction({ name: 'dogpark' });
5757

5858
expect(tracesSampler).toHaveBeenCalled();
5959
});
60+
61+
it('tolerates tracesSampler returning a boolean', () => {
62+
const tracesSampler = jest.fn().mockReturnValue(true);
63+
const hub = new Hub(new BrowserClient({ tracesSampler }));
64+
const transaction = hub.startTransaction({ name: 'dogpark' });
65+
66+
expect(tracesSampler).toHaveBeenCalled();
67+
expect(transaction.sampled).toBe(true);
68+
});
6069
});
6170

6271
describe('default sample context', () => {
@@ -133,9 +142,25 @@ describe('Hub', () => {
133142

134143
expect(tracesSampler).toHaveBeenCalledWith(expect.objectContaining({ location: dogParkLocation }));
135144
});
145+
146+
it("should add parent's sampling decision to default sample context", () => {
147+
const tracesSampler = jest.fn();
148+
const hub = new Hub(new BrowserClient({ tracesSampler }));
149+
const parentSamplingDecsion = false;
150+
151+
hub.startTransaction({
152+
name: 'dogpark',
153+
parentSpanId: '12312012',
154+
sampled: parentSamplingDecsion,
155+
});
156+
157+
expect(tracesSampler).toHaveBeenLastCalledWith(
158+
expect.objectContaining({ parentSampled: parentSamplingDecsion }),
159+
);
160+
});
136161
});
137162

138-
describe('while sampling', () => {
163+
describe('sample()', () => {
139164
it('should not sample transactions when tracing is disabled', () => {
140165
// neither tracesSampleRate nor tracesSampler is defined -> tracing disabled
141166
const hub = new Hub(new BrowserClient({}));
@@ -157,36 +182,41 @@ describe('Hub', () => {
157182

158183
expect(transaction.sampled).toBe(true);
159184
});
185+
});
160186

161-
it("should reject tracesSampleRates which aren't numbers", () => {
187+
describe('isValidSampleRate()', () => {
188+
it("should reject tracesSampleRates which aren't numbers or booleans", () => {
162189
const hub = new Hub(new BrowserClient({ tracesSampleRate: 'dogs!' as any }));
163190
hub.startTransaction({ name: 'dogpark' });
164191

165-
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be a number'));
192+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be a boolean or a number'));
166193
});
167194

195+
// the rate might be a boolean, but for our purposes, false is equivalent to 0 and true is equivalent to 1
168196
it('should reject tracesSampleRates less than 0', () => {
169197
const hub = new Hub(new BrowserClient({ tracesSampleRate: -26 }));
170198
hub.startTransaction({ name: 'dogpark' });
171199

172200
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be between 0 and 1'));
173201
});
174202

203+
// the rate might be a boolean, but for our purposes, false is equivalent to 0 and true is equivalent to 1
175204
it('should reject tracesSampleRates greater than 1', () => {
176205
const hub = new Hub(new BrowserClient({ tracesSampleRate: 26 }));
177206
hub.startTransaction({ name: 'dogpark' });
178207

179208
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be between 0 and 1'));
180209
});
181210

182-
it("should reject tracesSampler return values which aren't numbers", () => {
211+
it("should reject tracesSampler return values which aren't numbers or booleans", () => {
183212
const tracesSampler = jest.fn().mockReturnValue('dogs!');
184213
const hub = new Hub(new BrowserClient({ tracesSampler }));
185214
hub.startTransaction({ name: 'dogpark' });
186215

187-
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be a number'));
216+
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be a boolean or a number'));
188217
});
189218

219+
// the rate might be a boolean, but for our purposes, false is equivalent to 0 and true is equivalent to 1
190220
it('should reject tracesSampler return values less than 0', () => {
191221
const tracesSampler = jest.fn().mockReturnValue(-12);
192222
const hub = new Hub(new BrowserClient({ tracesSampler }));
@@ -195,6 +225,7 @@ describe('Hub', () => {
195225
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Sample rate must be between 0 and 1'));
196226
});
197227

228+
// the rate might be a boolean, but for our purposes, false is equivalent to 0 and true is equivalent to 1
198229
it('should reject tracesSampler return values greater than 1', () => {
199230
const tracesSampler = jest.fn().mockReturnValue(31);
200231
const hub = new Hub(new BrowserClient({ tracesSampler }));
@@ -204,14 +235,6 @@ describe('Hub', () => {
204235
});
205236
});
206237

207-
it('should propagate sampling decision to child spans', () => {
208-
const hub = new Hub(new BrowserClient({ tracesSampleRate: 0 }));
209-
const transaction = hub.startTransaction({ name: 'dogpark' });
210-
const child = transaction.startChild({ op: 'test' });
211-
212-
expect(child.sampled).toBe(false);
213-
});
214-
215238
it('should drop transactions with sampled = false', () => {
216239
const client = new BrowserClient({ tracesSampleRate: 0 });
217240
jest.spyOn(client, 'captureEvent');
@@ -226,5 +249,55 @@ describe('Hub', () => {
226249
expect(transaction.finish).toReturnWith(undefined);
227250
expect(client.captureEvent).not.toBeCalled();
228251
});
252+
253+
describe('sampling inheritance', () => {
254+
it('should propagate sampling decision to child spans', () => {
255+
const hub = new Hub(new BrowserClient({ tracesSampleRate: Math.random() }));
256+
const transaction = hub.startTransaction({ name: 'dogpark' });
257+
const child = transaction.startChild({ op: 'test' });
258+
259+
expect(child.sampled).toBe(transaction.sampled);
260+
});
261+
262+
it('should propagate sampling decision to child transactions in XHR header', () => {
263+
// TODO this doesn't currently happen, but it should
264+
});
265+
266+
it('should propagate sampling decision to child transactions in fetch header', () => {
267+
// TODO this doesn't currently happen, but it should
268+
});
269+
270+
it("should inherit parent's sampling decision when creating a new transaction if tracesSampler is undefined", () => {
271+
// tracesSampleRate = 1 means every transaction should end up with sampled = true, so make parent's decision the
272+
// opposite to prove that inheritance takes precedence over tracesSampleRate
273+
const hub = new Hub(new BrowserClient({ tracesSampleRate: 1 }));
274+
const parentSamplingDecsion = false;
275+
276+
const transaction = hub.startTransaction({
277+
name: 'dogpark',
278+
parentSpanId: '12312012',
279+
sampled: parentSamplingDecsion,
280+
});
281+
282+
expect(transaction.sampled).toBe(parentSamplingDecsion);
283+
});
284+
285+
it("should ignore parent's sampling decision when tracesSampler is defined", () => {
286+
// this tracesSampler causes every transaction to end up with sampled = true, so make parent's decision the
287+
// opposite to prove that tracesSampler takes precedence over inheritance
288+
const tracesSampler = () => true;
289+
const parentSamplingDecsion = false;
290+
291+
const hub = new Hub(new BrowserClient({ tracesSampler }));
292+
293+
const transaction = hub.startTransaction({
294+
name: 'dogpark',
295+
parentSpanId: '12312012',
296+
sampled: parentSamplingDecsion,
297+
});
298+
299+
expect(transaction.sampled).not.toBe(parentSamplingDecsion);
300+
});
301+
});
229302
});
230303
});

packages/types/src/options.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,10 @@ export interface Options {
132132
* Will automatically be passed a context object of default and optional custom data. See
133133
* {@link Transaction.sampleContext} and {@link Hub.startTransaction}.
134134
*
135-
* @returns A sample rate between 0 and 1 (0 drops the trace, 1 guarantees it will be sent).
135+
* @returns A sample rate between 0 and 1 (0 drops the trace, 1 guarantees it will be sent). Returning `true` is
136+
* equivalent to returning 1 and returning `false` is equivalent to returning 0.
136137
*/
137-
tracesSampler?(sampleContext: SampleContext): number;
138+
tracesSampler?(sampleContext: SampleContext): number | boolean;
138139

139140
/**
140141
* A callback invoked during event submission, allowing to optionally modify

0 commit comments

Comments
 (0)