Skip to content

Commit 81c9651

Browse files
authored
feat(browser): Allow collecting of pageload profiles (#9317)
Profiling is currently unable to profile page loads which creates a gap in the product as performance instrumentation seemingly can (via synthetic transactions) In order to bridge this gap, the plan is to provide a small JS snippet that users can insert into their document which will initialize and store a reference to the pageload profile on the sentry carrier. By doing so we can hook back into our finishTransaction codepath and send a profile associated to a transaction. Would love to hear early thoughts on this approach from SDK maintainers before I start adding test coverage.
1 parent f77bb6e commit 81c9651

File tree

5 files changed

+264
-132
lines changed

5 files changed

+264
-132
lines changed

packages/browser/src/profiling/hubextensions.ts

Lines changed: 21 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,15 @@
11
/* eslint-disable complexity */
2-
import { getCurrentHub } from '@sentry/core';
32
import type { Transaction } from '@sentry/types';
43
import { logger, uuid4 } from '@sentry/utils';
54

65
import { WINDOW } from '../helpers';
7-
import type { JSSelfProfile, JSSelfProfiler, JSSelfProfilerConstructor } from './jsSelfProfiling';
8-
import { addProfileToMap, isValidSampleRate } from './utils';
9-
10-
export const MAX_PROFILE_DURATION_MS = 30_000;
11-
// Keep a flag value to avoid re-initializing the profiler constructor. If it fails
12-
// once, it will always fail and this allows us to early return.
13-
let PROFILING_CONSTRUCTOR_FAILED = false;
14-
15-
/**
16-
* Check if profiler constructor is available.
17-
* @param maybeProfiler
18-
*/
19-
function isJSProfilerSupported(maybeProfiler: unknown): maybeProfiler is typeof JSSelfProfilerConstructor {
20-
return typeof maybeProfiler === 'function';
21-
}
6+
import type { JSSelfProfile } from './jsSelfProfiling';
7+
import {
8+
addProfileToGlobalCache,
9+
MAX_PROFILE_DURATION_MS,
10+
shouldProfileTransaction,
11+
startJSSelfProfile,
12+
} from './utils';
2213

2314
/**
2415
* Safety wrapper for startTransaction for the unlikely case that transaction starts before tracing is imported -
@@ -35,98 +26,24 @@ export function onProfilingStartRouteTransaction(transaction: Transaction | unde
3526
return transaction;
3627
}
3728

38-
return wrapTransactionWithProfiling(transaction);
29+
if (shouldProfileTransaction(transaction)) {
30+
return startProfileForTransaction(transaction);
31+
}
32+
33+
return transaction;
3934
}
4035

4136
/**
4237
* Wraps startTransaction and stopTransaction with profiling related logic.
43-
* startProfiling is called after the call to startTransaction in order to avoid our own code from
38+
* startProfileForTransaction is called after the call to startTransaction in order to avoid our own code from
4439
* being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction.
4540
*/
46-
export function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
47-
// Feature support check first
48-
const JSProfilerConstructor = WINDOW.Profiler;
49-
50-
if (!isJSProfilerSupported(JSProfilerConstructor)) {
51-
if (__DEBUG_BUILD__) {
52-
logger.log(
53-
'[Profiling] Profiling is not supported by this browser, Profiler interface missing on window object.',
54-
);
55-
}
56-
return transaction;
57-
}
58-
59-
// If constructor failed once, it will always fail, so we can early return.
60-
if (PROFILING_CONSTRUCTOR_FAILED) {
61-
if (__DEBUG_BUILD__) {
62-
logger.log('[Profiling] Profiling has been disabled for the duration of the current user session.');
63-
}
64-
return transaction;
65-
}
66-
67-
const client = getCurrentHub().getClient();
68-
const options = client && client.getOptions();
69-
if (!options) {
70-
__DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no options found.');
71-
return transaction;
72-
}
41+
export function startProfileForTransaction(transaction: Transaction): Transaction {
42+
// Start the profiler and get the profiler instance.
43+
const profiler = startJSSelfProfile();
7344

74-
// @ts-expect-error 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.');
81-
return transaction;
82-
}
83-
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;
96-
// Check if we should sample this profile
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-
);
104-
return transaction;
105-
}
106-
107-
// From initial testing, it seems that the minimum value for sampleInterval is 10ms.
108-
const samplingIntervalMS = 10;
109-
// Start the profiler
110-
const maxSamples = Math.floor(MAX_PROFILE_DURATION_MS / samplingIntervalMS);
111-
let profiler: JSSelfProfiler | undefined;
112-
113-
// Attempt to initialize the profiler constructor, if it fails, we disable profiling for the current user session.
114-
// This is likely due to a missing 'Document-Policy': 'js-profiling' header. We do not want to throw an error if this happens
115-
// as we risk breaking the user's application, so just disable profiling and log an error.
116-
try {
117-
profiler = new JSProfilerConstructor({ sampleInterval: samplingIntervalMS, maxBufferSize: maxSamples });
118-
} catch (e) {
119-
if (__DEBUG_BUILD__) {
120-
logger.log(
121-
"[Profiling] Failed to initialize the Profiling constructor, this is likely due to a missing 'Document-Policy': 'js-profiling' header.",
122-
);
123-
logger.log('[Profiling] Disabling profiling for current user session.');
124-
}
125-
PROFILING_CONSTRUCTOR_FAILED = true;
126-
}
127-
128-
// We failed to construct the profiler, fallback to original transaction - there is no need to log
129-
// anything as we already did that in the try/catch block.
45+
// We failed to construct the profiler, fallback to original transaction.
46+
// No need to log anything as this has already been logged in startProfile.
13047
if (!profiler) {
13148
return transaction;
13249
}
@@ -172,19 +89,9 @@ export function wrapTransactionWithProfiling(transaction: Transaction): Transact
17289
return null;
17390
}
17491

175-
// This is temporary - we will use the collected span data to evaluate
176-
// if deferring txn.finish until profiler resolves is a viable approach.
177-
const stopProfilerSpan = transaction.startChild({
178-
description: 'profiler.stop',
179-
op: 'profiler',
180-
origin: 'auto.profiler.browser',
181-
});
182-
18392
return profiler
18493
.stop()
185-
.then((p: JSSelfProfile): null => {
186-
stopProfilerSpan.finish();
187-
94+
.then((profile: JSSelfProfile): null => {
18895
if (maxDurationTimeoutID) {
18996
WINDOW.clearTimeout(maxDurationTimeoutID);
19097
maxDurationTimeoutID = undefined;
@@ -195,7 +102,7 @@ export function wrapTransactionWithProfiling(transaction: Transaction): Transact
195102
}
196103

197104
// In case of an overlapping transaction, stopProfiling may return null and silently ignore the overlapping profile.
198-
if (!p) {
105+
if (!profile) {
199106
if (__DEBUG_BUILD__) {
200107
logger.log(
201108
`[Profiling] profiler returned null profile for: ${transaction.name || transaction.description}`,
@@ -205,11 +112,10 @@ export function wrapTransactionWithProfiling(transaction: Transaction): Transact
205112
return null;
206113
}
207114

208-
addProfileToMap(profileId, p);
115+
addProfileToGlobalCache(profileId, profile);
209116
return null;
210117
})
211118
.catch(error => {
212-
stopProfilerSpan.finish();
213119
if (__DEBUG_BUILD__) {
214120
logger.log('[Profiling] error while stopping profiler:', error);
215121
}

packages/browser/src/profiling/integration.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import type { EventProcessor, Hub, Integration, Transaction } from '@sentry/type
22
import type { Profile } from '@sentry/types/src/profiling';
33
import { logger } from '@sentry/utils';
44

5-
import type { BrowserClient } from './../client';
6-
import { wrapTransactionWithProfiling } from './hubextensions';
5+
import { startProfileForTransaction } from './hubextensions';
76
import type { ProfiledEvent } from './utils';
87
import {
98
addProfilesToEnvelope,
109
createProfilingEvent,
1110
findProfiledTransactionsFromEnvelope,
12-
PROFILE_MAP,
11+
getActiveProfilesCount,
12+
isAutomatedPageLoadTransaction,
13+
shouldProfileTransaction,
14+
takeProfileFromGlobalCache,
1315
} from './utils';
1416

1517
/**
@@ -37,16 +39,29 @@ export class BrowserProfilingIntegration implements Integration {
3739
*/
3840
public setupOnce(_addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
3941
this.getCurrentHub = getCurrentHub;
40-
const client = this.getCurrentHub().getClient() as BrowserClient;
42+
43+
const hub = this.getCurrentHub();
44+
const client = hub.getClient();
45+
const scope = hub.getScope();
46+
47+
const transaction = scope.getTransaction();
48+
49+
if (transaction && isAutomatedPageLoadTransaction(transaction)) {
50+
if (shouldProfileTransaction(transaction)) {
51+
startProfileForTransaction(transaction);
52+
}
53+
}
4154

4255
if (client && typeof client.on === 'function') {
4356
client.on('startTransaction', (transaction: Transaction) => {
44-
wrapTransactionWithProfiling(transaction);
57+
if (shouldProfileTransaction(transaction)) {
58+
startProfileForTransaction(transaction);
59+
}
4560
});
4661

4762
client.on('beforeEnvelope', (envelope): void => {
4863
// if not profiles are in queue, there is nothing to add to the envelope.
49-
if (!PROFILE_MAP['size']) {
64+
if (!getActiveProfilesCount()) {
5065
return;
5166
}
5267

@@ -59,7 +74,13 @@ export class BrowserProfilingIntegration implements Integration {
5974

6075
for (const profiledTransaction of profiledTransactionEvents) {
6176
const context = profiledTransaction && profiledTransaction.contexts;
62-
const profile_id = context && context['profile'] && (context['profile']['profile_id'] as string);
77+
const profile_id = context && context['profile'] && context['profile']['profile_id'];
78+
79+
if (typeof profile_id !== 'string') {
80+
__DEBUG_BUILD__ &&
81+
logger.log('[Profiling] cannot find profile for a transaction without a profile context');
82+
continue;
83+
}
6384

6485
if (!profile_id) {
6586
__DEBUG_BUILD__ &&
@@ -72,15 +93,13 @@ export class BrowserProfilingIntegration implements Integration {
7293
delete context.profile;
7394
}
7495

75-
const profile = PROFILE_MAP.get(profile_id);
96+
const profile = takeProfileFromGlobalCache(profile_id);
7697
if (!profile) {
7798
__DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`);
7899
continue;
79100
}
80101

81-
PROFILE_MAP.delete(profile_id);
82102
const profileEvent = createProfilingEvent(profile_id, profile, profiledTransaction as ProfiledEvent);
83-
84103
if (profileEvent) {
85104
profilesToAddToEnvelope.push(profileEvent);
86105
}

packages/browser/src/profiling/jsSelfProfiling.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,12 @@ export type JSSelfProfile = {
2626
samples: JSSelfProfileSample[];
2727
};
2828

29-
type BufferFullCallback = (trace: JSSelfProfile) => void;
30-
3129
export interface JSSelfProfiler {
3230
sampleInterval: number;
3331
stopped: boolean;
3432

3533
stop: () => Promise<JSSelfProfile>;
36-
addEventListener(event: 'samplebufferfull', callback: BufferFullCallback): void;
34+
addEventListener(event: 'samplebufferfull', callback: (trace: JSSelfProfile) => void): void;
3735
}
3836

3937
export declare const JSSelfProfilerConstructor: {

0 commit comments

Comments
 (0)