Skip to content

Commit 328402a

Browse files
committed
feat: bring in hook related logic
1 parent 937cbd0 commit 328402a

File tree

7 files changed

+560
-177
lines changed

7 files changed

+560
-177
lines changed

packages/browser/src/profiling/hubextensions.ts

Lines changed: 117 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,135 @@
1-
import { getCurrentHub, getMainCarrier } from '@sentry/core';
2-
import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types';
1+
import { getCurrentHub } from '@sentry/core';
2+
import type { CustomSamplingContext, Transaction } from '@sentry/types';
33
import { logger, uuid4 } from '@sentry/utils';
44

5+
import type { BrowserClient } from '../client';
56
import { WINDOW } from '../helpers';
67
import type {
78
JSSelfProfile,
89
JSSelfProfiler,
910
JSSelfProfilerConstructor,
1011
ProcessedJSSelfProfile,
1112
} from './jsSelfProfiling';
12-
import { sendProfile } from './sendProfile';
13+
import { isValidSampleRate } from './utils';
1314

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

20-
// While we experiment, per transaction sampling interval will be more flexible to work with.
21-
type StartTransaction = (
22-
this: Hub,
23-
transactionContext: TransactionContext,
20+
// Takes a transaction and determines if it should be profiled or not. If it should be profiled, it returns the
21+
// profile_id, otherwise returns undefined. Takes care of setting profile context on transaction as well
22+
/**
23+
*
24+
*/
25+
export function maybeProfileTransaction(
26+
client: BrowserClient,
27+
transaction: Transaction,
2428
customSamplingContext?: CustomSamplingContext,
25-
) => Transaction | undefined;
29+
): string | undefined {
30+
// profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate. We dont perform
31+
// the actual multiplication to get the final rate, but we discard the profile if the transaction was sampled,
32+
// so anything after this block from here is based on the transaction sampling.
33+
if (!transaction.sampled) {
34+
return;
35+
}
36+
37+
// Client and options are required for profiling
38+
if (!client) {
39+
__DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no client found.');
40+
return;
41+
}
42+
43+
const options = client.getOptions();
44+
if (!options) {
45+
__DEBUG_BUILD__ && logger.log('[Profiling] Profiling disabled, no options found.');
46+
return;
47+
}
48+
49+
// @ts-ignore profilesSampler is not part of the browser options yet
50+
const profilesSampler = options.profilesSampler;
51+
// @ts-ignore profilesSampleRate is not part of the browser options yet
52+
let profilesSampleRate: number | boolean | undefined = options.profilesSampleRate;
53+
54+
// Prefer sampler to sample rate if both are provided.
55+
if (typeof profilesSampler === 'function') {
56+
profilesSampleRate = profilesSampler({ transactionContext: transaction.toContext(), ...customSamplingContext });
57+
}
58+
59+
// Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The
60+
// only valid values are booleans or numbers between 0 and 1.)
61+
if (!isValidSampleRate(profilesSampleRate)) {
62+
__DEBUG_BUILD__ && logger.warn('[Profiling] Discarding profile because of invalid sample rate.');
63+
return;
64+
}
65+
66+
// if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped
67+
if (!profilesSampleRate) {
68+
__DEBUG_BUILD__ &&
69+
logger.log(
70+
`[Profiling] Discarding profile because ${
71+
typeof profilesSampler === 'function'
72+
? 'profileSampler returned 0 or false'
73+
: 'a negative sampling decision was inherited or profileSampleRate is set to 0'
74+
}`,
75+
);
76+
return;
77+
}
78+
79+
// Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is
80+
// a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false.
81+
const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate;
82+
// Check if we should sample this profile
83+
if (!sampled) {
84+
__DEBUG_BUILD__ &&
85+
logger.log(
86+
`[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number(
87+
profilesSampleRate,
88+
)})`,
89+
);
90+
return;
91+
}
92+
93+
const profile_id = uuid4();
94+
CpuProfilerBindings.startProfiling(profile_id);
95+
96+
__DEBUG_BUILD__ && logger.log(`[Profiling] started profiling transaction: ${transaction.name}`);
97+
98+
// set transaction context - do this regardless if profiling fails down the line
99+
// so that we can still see the profile_id in the transaction context
100+
return profile_id;
101+
}
102+
103+
/**
104+
*
105+
*/
106+
export function stopTransactionProfile(
107+
transaction: Transaction,
108+
profile_id: string | undefined,
109+
): ReturnType<(typeof CpuProfilerBindings)['stopProfiling']> | null {
110+
// Should not happen, but satisfy the type checker and be safe regardless.
111+
if (!profile_id) {
112+
return null;
113+
}
114+
115+
const profile = CpuProfilerBindings.stopProfiling(profile_id);
116+
117+
__DEBUG_BUILD__ && logger.log(`[Profiling] stopped profiling of transaction: ${transaction.name}`);
118+
119+
// In case of an overlapping transaction, stopProfiling may return null and silently ignore the overlapping profile.
120+
if (!profile) {
121+
__DEBUG_BUILD__ &&
122+
logger.log(
123+
`[Profiling] profiler returned null profile for: ${transaction.name}`,
124+
'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started',
125+
);
126+
return null;
127+
}
128+
129+
// Assign profile_id to the profile
130+
profile.profile_id = profile_id;
131+
return profile;
132+
}
26133

27134
/**
28135
* Check if profiler constructor is available.
@@ -247,56 +354,3 @@ function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
247354
transaction.finish = profilingWrappedTransactionFinish;
248355
return transaction;
249356
}
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-
}

packages/browser/src/profiling/integration.ts

Lines changed: 123 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1-
import type { Event, EventProcessor, Integration } from '@sentry/types';
1+
import type { BrowserClient } from '@sentry/browser';
2+
import type { Event, EventProcessor, Hub, Integration, Transaction } from '@sentry/types';
23
import { logger } from '@sentry/utils';
34

45
import { PROFILING_EVENT_CACHE } from './cache';
5-
import { addProfilingExtensionMethods } from './hubextensions';
6+
import { MAX_PROFILE_DURATION_MS, maybeProfileTransaction } from './hubextensions';
7+
import { addProfilesToEnvelope, findProfiledTransactionsFromEnvelope } from './utils';
8+
9+
const MAX_PROFILE_QUEUE_LENGTH = 50;
10+
const PROFILE_QUEUE: RawThreadCpuProfile[] = [];
11+
const PROFILE_TIMEOUTS: Record<string, NodeJS.Timeout> = {};
12+
13+
function addToProfileQueue(profile: RawThreadCpuProfile): void {
14+
PROFILE_QUEUE.push(profile);
15+
16+
// We only want to keep the last n profiles in the queue.
17+
if (PROFILE_QUEUE.length > MAX_PROFILE_QUEUE_LENGTH) {
18+
PROFILE_QUEUE.shift();
19+
}
20+
}
621

722
/**
823
* Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"]
@@ -15,19 +30,116 @@ import { addProfilingExtensionMethods } from './hubextensions';
1530
*/
1631
export class BrowserProfilingIntegration implements Integration {
1732
public readonly name: string = 'BrowserProfilingIntegration';
33+
public getCurrentHub?: () => Hub = undefined;
1834

1935
/**
2036
* @inheritDoc
2137
*/
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));
38+
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
39+
this.getCurrentHub = getCurrentHub;
40+
const client = this.getCurrentHub().getClient() as BrowserClient;
41+
42+
if (client && typeof client.on === 'function') {
43+
client.on('startTransaction', (transaction: Transaction) => {
44+
const profile_id = maybeProfileTransaction(client, transaction, undefined);
45+
46+
if (profile_id) {
47+
const options = client.getOptions();
48+
// Not intended for external use, hence missing types, but we want to profile a couple of things at Sentry that
49+
// currently exceed the default timeout set by the SDKs.
50+
const maxProfileDurationMs =
51+
(options._experiments && options._experiments['maxProfileDurationMs']) || MAX_PROFILE_DURATION_MS;
52+
53+
// Enqueue a timeout to prevent profiles from running over max duration.
54+
if (PROFILE_TIMEOUTS[profile_id]) {
55+
global.clearTimeout(PROFILE_TIMEOUTS[profile_id]);
56+
delete PROFILE_TIMEOUTS[profile_id];
57+
}
58+
59+
PROFILE_TIMEOUTS[profile_id] = global.setTimeout(() => {
60+
__DEBUG_BUILD__ &&
61+
logger.log('[Profiling] max profile duration elapsed, stopping profiling for:', transaction.name);
62+
63+
const profile = stopTransactionProfile(transaction, profile_id);
64+
if (profile) {
65+
addToProfileQueue(profile);
66+
}
67+
}, maxProfileDurationMs);
68+
69+
transaction.setContext('profile', { profile_id });
70+
// @ts-expect-error profile_id is not part of the metadata type
71+
transaction.setMetadata({ profile_id: profile_id });
72+
}
73+
});
74+
75+
client.on('finishTransaction', transaction => {
76+
// @ts-expect-error profile_id is not part of the metadata type
77+
const profile_id = transaction && transaction.metadata && transaction.metadata.profile_id;
78+
if (profile_id) {
79+
if (PROFILE_TIMEOUTS[profile_id]) {
80+
global.clearTimeout(PROFILE_TIMEOUTS[profile_id]);
81+
delete PROFILE_TIMEOUTS[profile_id];
82+
}
83+
const profile = stopTransactionProfile(transaction, profile_id);
84+
85+
if (profile) {
86+
addToProfileQueue(profile);
87+
}
88+
}
89+
});
90+
91+
client.on('beforeEnvelope', (envelope): void => {
92+
// if not profiles are in queue, there is nothing to add to the envelope.
93+
if (!PROFILE_QUEUE.length) {
94+
return;
95+
}
96+
97+
const profiledTransactionEvents = findProfiledTransactionsFromEnvelope(envelope);
98+
if (!profiledTransactionEvents.length) {
99+
return;
100+
}
101+
102+
const profilesToAddToEnvelope: Profile[] = [];
103+
104+
for (const profiledTransaction of profiledTransactionEvents) {
105+
const profile_id = profiledTransaction?.contexts?.['profile']?.['profile_id'];
106+
107+
if (!profile_id) {
108+
throw new TypeError('[Profiling] cannot find profile for a transaction without a profile context');
109+
}
110+
111+
// Remove the profile from the transaction context before sending, relay will take care of the rest.
112+
if (profiledTransaction?.contexts?.['.profile']) {
113+
delete profiledTransaction.contexts.profile;
114+
}
115+
116+
// We need to find both a profile and a transaction event for the same profile_id.
117+
const profileIndex = PROFILE_QUEUE.findIndex(p => p.profile_id === profile_id);
118+
if (profileIndex === -1) {
119+
__DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`);
120+
continue;
121+
}
122+
123+
const cpuProfile = PROFILE_QUEUE[profileIndex];
124+
if (!cpuProfile) {
125+
__DEBUG_BUILD__ && logger.log(`[Profiling] Could not retrieve profile for transaction: ${profile_id}`);
126+
continue;
127+
}
128+
129+
// Remove the profile from the queue.
130+
PROFILE_QUEUE.splice(profileIndex, 1);
131+
const profile = createProfilingEvent(cpuProfile, profiledTransaction);
132+
133+
if (profile) {
134+
profilesToAddToEnvelope.push(profile);
135+
}
136+
}
137+
138+
addProfilesToEnvelope(envelope, profilesToAddToEnvelope);
139+
});
140+
} else {
141+
logger.warn('[Profiling] Client does not support hooks, profiling will be disabled');
142+
}
31143
}
32144

33145
/**

packages/browser/src/profiling/jsSelfProfiling.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { DebugImage } from '@sentry/types';
12
// Type definitions for https://wicg.github.io/js-self-profiling/
23
type JSSelfProfileSampleMarker = 'script' | 'gc' | 'style' | 'layout' | 'paint' | 'other';
34

@@ -95,14 +96,7 @@ export interface SentryProfile {
9596
platform: string;
9697
profile: ThreadCpuProfile;
9798
debug_meta?: {
98-
images: {
99-
debug_id: string;
100-
image_addr: string;
101-
code_file: string;
102-
type: string;
103-
image_size: number;
104-
image_vmaddr: string;
105-
}[];
99+
images: DebugImage[];
106100
};
107101
transactions: {
108102
name: string;

0 commit comments

Comments
 (0)