Skip to content

Commit 947db1b

Browse files
authored
feat(browser): send profiles in same envelope as transactions (#8375)
This change ensures that browser profiles are sent in the same envelope as transactions and enables sourcemap support.
1 parent 22b5887 commit 947db1b

File tree

7 files changed

+350
-516
lines changed

7 files changed

+350
-516
lines changed
Lines changed: 60 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,17 @@
1-
import { getCurrentHub, getMainCarrier } from '@sentry/core';
2-
import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types';
1+
/* eslint-disable complexity */
2+
import { getCurrentHub } from '@sentry/core';
3+
import type { Transaction } from '@sentry/types';
34
import { logger, uuid4 } from '@sentry/utils';
45

56
import { WINDOW } from '../helpers';
6-
import type {
7-
JSSelfProfile,
8-
JSSelfProfiler,
9-
JSSelfProfilerConstructor,
10-
ProcessedJSSelfProfile,
11-
} from './jsSelfProfiling';
12-
import { sendProfile } from './sendProfile';
7+
import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor } from './jsSelfProfiling';
8+
import { addProfileToMap, isValidSampleRate } from './utils';
139

14-
// Max profile duration.
15-
const MAX_PROFILE_DURATION_MS = 30_000;
10+
export const MAX_PROFILE_DURATION_MS = 30_000;
1611
// Keep a flag value to avoid re-initializing the profiler constructor. If it fails
1712
// once, it will always fail and this allows us to early return.
1813
let PROFILING_CONSTRUCTOR_FAILED = false;
1914

20-
// While we experiment, per transaction sampling interval will be more flexible to work with.
21-
type StartTransaction = (
22-
this: Hub,
23-
transactionContext: TransactionContext,
24-
customSamplingContext?: CustomSamplingContext,
25-
) => Transaction | undefined;
26-
2715
/**
2816
* Check if profiler constructor is available.
2917
* @param maybeProfiler
@@ -55,7 +43,7 @@ export function onProfilingStartRouteTransaction(transaction: Transaction | unde
5543
* startProfiling is called after the call to startTransaction in order to avoid our own code from
5644
* being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction.
5745
*/
58-
function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
46+
export function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
5947
// Feature support check first
6048
const JSProfilerConstructor = WINDOW.Profiler;
6149

@@ -68,14 +56,6 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
6856
return transaction;
6957
}
7058

71-
// profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate.
72-
if (!transaction.sampled) {
73-
if (__DEBUG_BUILD__) {
74-
logger.log('[Profiling] Transaction is not sampled, skipping profiling');
75-
}
76-
return transaction;
77-
}
78-
7959
// If constructor failed once, it will always fail, so we can early return.
8060
if (PROFILING_CONSTRUCTOR_FAILED) {
8161
if (__DEBUG_BUILD__) {
@@ -86,21 +66,41 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
8666

8767
const client = getCurrentHub().getClient();
8868
const options = client && client.getOptions();
69+
if (!options) {
70+
__DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no options found.');
71+
return transaction;
72+
}
8973

90-
// @ts-ignore not part of the browser options yet
91-
const profilesSampleRate = (options && options.profilesSampleRate) || 0;
92-
if (profilesSampleRate === undefined) {
93-
if (__DEBUG_BUILD__) {
94-
logger.log('[Profiling] Profiling disabled, enable it by setting `profilesSampleRate` option to SDK init call.');
95-
}
74+
// @ts-ignore profilesSampleRate is not part of the browser options yet
75+
const profilesSampleRate: number | boolean | undefined = options.profilesSampleRate;
76+
77+
// Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The
78+
// only valid values are booleans or numbers between 0 and 1.)
79+
if (!isValidSampleRate(profilesSampleRate)) {
80+
__DEBUG_BUILD__ && logger.warn('[Profiling] Discarding profile because of invalid sample rate.');
9681
return transaction;
9782
}
9883

84+
// if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped
85+
if (!profilesSampleRate) {
86+
__DEBUG_BUILD__ &&
87+
logger.log(
88+
'[Profiling] Discarding profile because a negative sampling decision was inherited or profileSampleRate is set to 0',
89+
);
90+
return transaction;
91+
}
92+
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+
const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate;
9996
// Check if we should sample this profile
100-
if (Math.random() > profilesSampleRate) {
101-
if (__DEBUG_BUILD__) {
102-
logger.log('[Profiling] Skip profiling transaction due to sampling.');
103-
}
97+
if (!sampled) {
98+
__DEBUG_BUILD__ &&
99+
logger.log(
100+
`[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number(
101+
profilesSampleRate,
102+
)})`,
103+
);
104104
return transaction;
105105
}
106106

@@ -147,19 +147,19 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
147147
// event of an error or user mistake (calling transaction.finish multiple times), it is important that the behavior of onProfileHandler
148148
// is idempotent as we do not want any timings or profiles to be overriden by the last call to onProfileHandler.
149149
// After the original finish method is called, the event will be reported through the integration and delegated to transport.
150-
let processedProfile: ProcessedJSSelfProfile | null = null;
150+
const processedProfile: JSSelfProfile | null = null;
151151

152152
/**
153153
* Idempotent handler for profile stop
154154
*/
155-
function onProfileHandler(): void {
155+
async function onProfileHandler(): Promise<null> {
156156
// Check if the profile exists and return it the behavior has to be idempotent as users may call transaction.finish multiple times.
157157
if (!transaction) {
158-
return;
158+
return null;
159159
}
160160
// Satisfy the type checker, but profiler will always be defined here.
161161
if (!profiler) {
162-
return;
162+
return null;
163163
}
164164
if (processedProfile) {
165165
if (__DEBUG_BUILD__) {
@@ -169,12 +169,12 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
169169
'already exists, returning early',
170170
);
171171
}
172-
return;
172+
return null;
173173
}
174174

175-
profiler
175+
return profiler
176176
.stop()
177-
.then((p: JSSelfProfile): void => {
177+
.then((p: JSSelfProfile): null => {
178178
if (maxDurationTimeoutID) {
179179
WINDOW.clearTimeout(maxDurationTimeoutID);
180180
maxDurationTimeoutID = undefined;
@@ -192,16 +192,11 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
192192
'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started',
193193
);
194194
}
195-
return;
196-
}
197-
198-
// If a profile has less than 2 samples, it is not useful and should be discarded.
199-
if (p.samples.length < 2) {
200-
return;
195+
return null;
201196
}
202197

203-
processedProfile = { ...p, profile_id: profileId };
204-
sendProfile(profileId, processedProfile);
198+
addProfileToMap(profileId, p);
199+
return null;
205200
})
206201
.catch(error => {
207202
if (__DEBUG_BUILD__) {
@@ -219,6 +214,7 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
219214
transaction.name || transaction.description,
220215
);
221216
}
217+
// If the timeout exceeds, we want to stop profiling, but not finish the transaction
222218
void onProfileHandler();
223219
}, MAX_PROFILE_DURATION_MS);
224220

@@ -230,73 +226,26 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
230226
* startProfiling is called after the call to startTransaction in order to avoid our own code from
231227
* being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction.
232228
*/
233-
function profilingWrappedTransactionFinish(): Promise<Transaction> {
229+
function profilingWrappedTransactionFinish(): Transaction {
234230
if (!transaction) {
235231
return originalFinish();
236232
}
237233
// onProfileHandler should always return the same profile even if this is called multiple times.
238234
// Always call onProfileHandler to ensure stopProfiling is called and the timeout is cleared.
239-
onProfileHandler();
240-
241-
// Set profile context
242-
transaction.setContext('profile', { profile_id: profileId });
235+
void onProfileHandler().then(
236+
() => {
237+
transaction.setContext('profile', { profile_id: profileId });
238+
originalFinish();
239+
},
240+
() => {
241+
// If onProfileHandler fails, we still want to call the original finish method.
242+
originalFinish();
243+
},
244+
);
243245

244-
return originalFinish();
246+
return transaction;
245247
}
246248

247249
transaction.finish = profilingWrappedTransactionFinish;
248250
return transaction;
249251
}
250-
251-
/**
252-
* Wraps startTransaction with profiling logic. This is done automatically by the profiling integration.
253-
*/
254-
function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: StartTransaction): StartTransaction {
255-
return function wrappedStartTransaction(
256-
this: Hub,
257-
transactionContext: TransactionContext,
258-
customSamplingContext?: CustomSamplingContext,
259-
): Transaction | undefined {
260-
const transaction: Transaction | undefined = startTransaction.call(this, transactionContext, customSamplingContext);
261-
if (transaction === undefined) {
262-
if (__DEBUG_BUILD__) {
263-
logger.log('[Profiling] Transaction is undefined, skipping profiling');
264-
}
265-
return transaction;
266-
}
267-
268-
return wrapTransactionWithProfiling(transaction);
269-
};
270-
}
271-
272-
/**
273-
* Patches startTransaction and stopTransaction with profiling logic.
274-
*/
275-
export function addProfilingExtensionMethods(): void {
276-
const carrier = getMainCarrier();
277-
if (!carrier.__SENTRY__) {
278-
if (__DEBUG_BUILD__) {
279-
logger.log("[Profiling] Can't find main carrier, profiling won't work.");
280-
}
281-
return;
282-
}
283-
carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {};
284-
285-
if (!carrier.__SENTRY__.extensions['startTransaction']) {
286-
if (__DEBUG_BUILD__) {
287-
logger.log(
288-
'[Profiling] startTransaction does not exists, profiling will not work. Make sure you import @sentry/tracing package before @sentry/profiling-node as import order matters.',
289-
);
290-
}
291-
return;
292-
}
293-
294-
if (__DEBUG_BUILD__) {
295-
logger.log('[Profiling] startTransaction exists, patching it with profiling functionality...');
296-
}
297-
298-
carrier.__SENTRY__.extensions['startTransaction'] = __PRIVATE__wrapStartTransactionWithProfiling(
299-
// This is already patched by sentry/tracing, we are going to re-patch it...
300-
carrier.__SENTRY__.extensions['startTransaction'] as StartTransaction,
301-
);
302-
}
Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
import type { Event, EventProcessor, Integration } from '@sentry/types';
1+
import type { EventProcessor, Hub, Integration, Transaction } from '@sentry/types';
2+
import type { Profile } from '@sentry/types/src/profiling';
23
import { logger } from '@sentry/utils';
34

4-
import { PROFILING_EVENT_CACHE } from './cache';
5-
import { addProfilingExtensionMethods } from './hubextensions';
5+
import type { BrowserClient } from './../client';
6+
import { wrapTransactionWithProfiling } from './hubextensions';
7+
import type { ProfiledEvent } from './utils';
8+
import {
9+
addProfilesToEnvelope,
10+
createProfilingEvent,
11+
findProfiledTransactionsFromEnvelope,
12+
PROFILE_MAP,
13+
} from './utils';
614

715
/**
816
* Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"]
@@ -15,34 +23,66 @@ import { addProfilingExtensionMethods } from './hubextensions';
1523
*/
1624
export class BrowserProfilingIntegration implements Integration {
1725
public readonly name: string = 'BrowserProfilingIntegration';
26+
public getCurrentHub?: () => Hub = undefined;
1827

1928
/**
2029
* @inheritDoc
2130
*/
22-
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void): void {
23-
// Patching the hub to add the extension methods.
24-
// Warning: we have an implicit dependency on import order and we will fail patching if the constructor of
25-
// BrowserProfilingIntegration is called before @sentry/tracing is imported. This is because we need to patch
26-
// the methods of @sentry/tracing which are patched as a side effect of importing @sentry/tracing.
27-
addProfilingExtensionMethods();
28-
29-
// Add our event processor
30-
addGlobalEventProcessor(this.handleGlobalEvent.bind(this));
31-
}
31+
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
32+
this.getCurrentHub = getCurrentHub;
33+
const client = this.getCurrentHub().getClient() as BrowserClient;
3234

33-
/**
34-
* @inheritDoc
35-
*/
36-
public handleGlobalEvent(event: Event): Event {
37-
const profileId = event.contexts && event.contexts['profile'] && event.contexts['profile']['profile_id'];
38-
39-
if (profileId && typeof profileId === 'string') {
40-
if (__DEBUG_BUILD__) {
41-
logger.log('[Profiling] Profiling event found, caching it.');
42-
}
43-
PROFILING_EVENT_CACHE.add(profileId, event);
44-
}
35+
if (client && typeof client.on === 'function') {
36+
client.on('startTransaction', (transaction: Transaction) => {
37+
wrapTransactionWithProfiling(transaction);
38+
});
39+
40+
client.on('beforeEnvelope', (envelope): void => {
41+
// if not profiles are in queue, there is nothing to add to the envelope.
42+
if (!PROFILE_MAP['size']) {
43+
return;
44+
}
45+
46+
const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope);
47+
if (!profiledTransactionEvents.length) {
48+
return;
49+
}
50+
51+
const profilesToAddToEnvelope: Profile[] = [];
4552

46-
return event;
53+
for (const profiledTransaction of profiledTransactionEvents) {
54+
const context = profiledTransaction && profiledTransaction.contexts;
55+
const profile_id = context && context['profile'] && (context['profile']['profile_id'] as string);
56+
57+
if (!profile_id) {
58+
__DEBUG_BUILD__ &&
59+
logger.log('[Profiling] cannot find profile for a transaction without a profile context');
60+
continue;
61+
}
62+
63+
// Remove the profile from the transaction context before sending, relay will take care of the rest.
64+
if (context && context['profile']) {
65+
delete context.profile;
66+
}
67+
68+
const profile = PROFILE_MAP.get(profile_id);
69+
if (!profile) {
70+
__DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`);
71+
continue;
72+
}
73+
74+
PROFILE_MAP.delete(profile_id);
75+
const profileEvent = createProfilingEvent(profile_id, profile, profiledTransaction as ProfiledEvent);
76+
77+
if (profileEvent) {
78+
profilesToAddToEnvelope.push(profileEvent);
79+
}
80+
}
81+
82+
addProfilesToEnvelope(envelope, profilesToAddToEnvelope);
83+
});
84+
} else {
85+
logger.warn('[Profiling] Client does not support hooks, profiling will be disabled');
86+
}
4787
}
4888
}

packages/browser/src/profiling/jsSelfProfiling.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,6 @@ export type JSSelfProfile = {
2626
samples: JSSelfProfileSample[];
2727
};
2828

29-
export interface ProcessedJSSelfProfile extends JSSelfProfile {
30-
profile_id: string;
31-
}
32-
3329
type BufferFullCallback = (trace: JSSelfProfile) => void;
3430

3531
export interface JSSelfProfiler {
@@ -49,7 +45,3 @@ declare global {
4945
Profiler: typeof JSSelfProfilerConstructor | undefined;
5046
}
5147
}
52-
53-
export interface RawThreadCpuProfile extends JSSelfProfile {
54-
profile_id: string;
55-
}

0 commit comments

Comments
 (0)