Skip to content

Commit 45158f4

Browse files
JonasBak-fishlforst
authored
feat(profiling): js self profiling (#7273)
* feat(profiling): add boilerplate * feat(profiling): add files and remove isDebugBuild in favor of __DEBUG_BUILD__ * ref(lint): fix linter errors on most files * ref(profile): convert half the profile * deps(sentry): start profiler * ref(profiling): cleanup warnings and add os/device info * rollback yarn change * feat(hub): move start transaction * Remove span.start for profiler.stop so that we don't have idletimeout causing long spans again * feat(build): build esm/cjs and types * ref(profiling): update txn to txn|undefined type * test(profiling): add utils and tests for browser profiling integration * test(tracing): assert onStartRouteTransaction is called by route instrumentation * fix(verdaccio): add pkg to verdaccio config * eslint: run --fix * fix(profile): change platform to javascript * ref(profiling): move profiling under browser package * ref(profiling): remove undefined from txn type union in wrapStartTransaction * fix(profiling): fix test * ref(profiling): rename profile and move it under types * chore(profiling): run linters * ref(profiling): split sendProfile to avoid a circular ref * ref(profiling): split cache * chore(profiling): sort imports * Update packages/browser/src/profiling/hubextensions.ts Co-authored-by: Luca Forstner <[email protected]> * Update packages/browser/src/profiling/integration.ts Co-authored-by: Luca Forstner <[email protected]> * Update packages/browser/src/profiling/integration.ts * lint --------- Co-authored-by: k-fish <[email protected]> Co-authored-by: Luca Forstner <[email protected]>
1 parent a88a8bf commit 45158f4

File tree

12 files changed

+1332
-2
lines changed

12 files changed

+1332
-2
lines changed

packages/browser/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ export { Replay } from '@sentry/replay';
3232
// __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_BEGIN__
3333
export { makeBrowserOfflineTransport } from './transports/offline';
3434
// __ROLLUP_EXCLUDE_OFFLINE_FROM_BUNDLES_END__
35+
36+
// __ROLLUP_EXCLUDE_BROWSER_PROFILING_FROM_BUNDLES_BEGIN__
37+
export { onProfilingStartRouteTransaction } from './profiling/hubextensions';
38+
export { BrowserProfilingIntegration } from './profiling/integration';
39+
// __ROLLUP_EXCLUDE_BROWSER_PROFILING_FROM_BUNDLES_END__
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Event } from '@sentry/types';
2+
3+
/**
4+
* Creates a cache that evicts keys in fifo order
5+
* @param size {Number}
6+
*/
7+
export function makeProfilingCache<Key extends string, Value extends Event>(
8+
size: number,
9+
): {
10+
get: (key: Key) => Value | undefined;
11+
add: (key: Key, value: Value) => void;
12+
delete: (key: Key) => boolean;
13+
clear: () => void;
14+
size: () => number;
15+
} {
16+
// Maintain a fifo queue of keys, we cannot rely on Object.keys as the browser may not support it.
17+
let evictionOrder: Key[] = [];
18+
let cache: Record<string, Value> = {};
19+
20+
return {
21+
add(key: Key, value: Value) {
22+
while (evictionOrder.length >= size) {
23+
// shift is O(n) but this is small size and only happens if we are
24+
// exceeding the cache size so it should be fine.
25+
const evictCandidate = evictionOrder.shift();
26+
27+
if (evictCandidate !== undefined) {
28+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
29+
delete cache[evictCandidate];
30+
}
31+
}
32+
33+
// in case we have a collision, delete the old key.
34+
if (cache[key]) {
35+
this.delete(key);
36+
}
37+
38+
evictionOrder.push(key);
39+
cache[key] = value;
40+
},
41+
clear() {
42+
cache = {};
43+
evictionOrder = [];
44+
},
45+
get(key: Key): Value | undefined {
46+
return cache[key];
47+
},
48+
size() {
49+
return evictionOrder.length;
50+
},
51+
// Delete cache key and return true if it existed, false otherwise.
52+
delete(key: Key): boolean {
53+
if (!cache[key]) {
54+
return false;
55+
}
56+
57+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
58+
delete cache[key];
59+
60+
for (let i = 0; i < evictionOrder.length; i++) {
61+
if (evictionOrder[i] === key) {
62+
evictionOrder.splice(i, 1);
63+
break;
64+
}
65+
}
66+
67+
return true;
68+
},
69+
};
70+
}
71+
72+
export const PROFILING_EVENT_CACHE = makeProfilingCache<string, Event>(20);
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
import { getCurrentHub, getMainCarrier } from '@sentry/core';
2+
import type { CustomSamplingContext, Hub, Transaction, TransactionContext } from '@sentry/types';
3+
import { logger, uuid4 } from '@sentry/utils';
4+
5+
import { WINDOW } from '../helpers';
6+
import type { JSSelfProfile, JSSelfProfiler, ProcessedJSSelfProfile } from './jsSelfProfiling';
7+
import { sendProfile } from './sendProfile';
8+
9+
// Max profile duration.
10+
const MAX_PROFILE_DURATION_MS = 30_000;
11+
12+
// While we experiment, per transaction sampling interval will be more flexible to work with.
13+
type StartTransaction = (
14+
this: Hub,
15+
transactionContext: TransactionContext,
16+
customSamplingContext?: CustomSamplingContext,
17+
) => Transaction | undefined;
18+
19+
/**
20+
* Check if profiler constructor is available.
21+
* @param maybeProfiler
22+
*/
23+
function isJSProfilerSupported(maybeProfiler: unknown): maybeProfiler is typeof JSSelfProfiler {
24+
return typeof maybeProfiler === 'function';
25+
}
26+
27+
/**
28+
* Safety wrapper for startTransaction for the unlikely case that transaction starts before tracing is imported -
29+
* if that happens we want to avoid throwing an error from profiling code.
30+
* see https://github.com/getsentry/sentry-javascript/issues/4731.
31+
*
32+
* @experimental
33+
*/
34+
export function onProfilingStartRouteTransaction(transaction: Transaction | undefined): Transaction | undefined {
35+
if (!transaction) {
36+
if (__DEBUG_BUILD__) {
37+
logger.log('[Profiling] Transaction is undefined, skipping profiling');
38+
}
39+
return transaction;
40+
}
41+
42+
return wrapTransactionWithProfiling(transaction);
43+
}
44+
45+
/**
46+
* Wraps startTransaction and stopTransaction with profiling related logic.
47+
* startProfiling is called after the call to startTransaction in order to avoid our own code from
48+
* being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction.
49+
*/
50+
function wrapTransactionWithProfiling(transaction: Transaction): Transaction {
51+
// Feature support check first
52+
const JSProfiler = WINDOW.Profiler;
53+
if (!isJSProfilerSupported(JSProfiler)) {
54+
if (__DEBUG_BUILD__) {
55+
logger.log(
56+
'[Profiling] Profiling is not supported by this browser, Profiler interface missing on window object.',
57+
);
58+
}
59+
return transaction;
60+
}
61+
62+
// profilesSampleRate is multiplied with tracesSampleRate to get the final sampling rate.
63+
if (!transaction.sampled) {
64+
if (__DEBUG_BUILD__) {
65+
logger.log('[Profiling] Transaction is not sampled, skipping profiling');
66+
}
67+
return transaction;
68+
}
69+
70+
const client = getCurrentHub().getClient();
71+
const options = client && client.getOptions();
72+
73+
// @ts-ignore not part of the browser options yet
74+
const profilesSampleRate = (options && options.profilesSampleRate) || 0;
75+
if (profilesSampleRate === undefined) {
76+
if (__DEBUG_BUILD__) {
77+
logger.log('[Profiling] Profiling disabled, enable it by setting `profilesSampleRate` option to SDK init call.');
78+
}
79+
return transaction;
80+
}
81+
82+
// Check if we should sample this profile
83+
if (Math.random() > profilesSampleRate) {
84+
if (__DEBUG_BUILD__) {
85+
logger.log('[Profiling] Skip profiling transaction due to sampling.');
86+
}
87+
return transaction;
88+
}
89+
90+
// From initial testing, it seems that the minimum value for sampleInterval is 10ms.
91+
const samplingIntervalMS = 10;
92+
// Start the profiler
93+
const maxSamples = Math.floor(MAX_PROFILE_DURATION_MS / samplingIntervalMS);
94+
const profiler = new JSProfiler({ sampleInterval: samplingIntervalMS, maxBufferSize: maxSamples });
95+
if (__DEBUG_BUILD__) {
96+
logger.log(`[Profiling] started profiling transaction: ${transaction.name || transaction.description}`);
97+
}
98+
99+
// We create "unique" transaction names to avoid concurrent transactions with same names
100+
// from being ignored by the profiler. From here on, only this transaction name should be used when
101+
// calling the profiler methods. Note: we log the original name to the user to avoid confusion.
102+
const profileId = uuid4();
103+
104+
// A couple of important things to note here:
105+
// `CpuProfilerBindings.stopProfiling` will be scheduled to run in 30seconds in order to exceed max profile duration.
106+
// Whichever of the two (transaction.finish/timeout) is first to run, the profiling will be stopped and the gathered profile
107+
// will be processed when the original transaction is finished. Since onProfileHandler can be invoked multiple times in the
108+
// event of an error or user mistake (calling transaction.finish multiple times), it is important that the behavior of onProfileHandler
109+
// is idempotent as we do not want any timings or profiles to be overriden by the last call to onProfileHandler.
110+
// After the original finish method is called, the event will be reported through the integration and delegated to transport.
111+
let processedProfile: ProcessedJSSelfProfile | null = null;
112+
113+
/**
114+
* Idempotent handler for profile stop
115+
*/
116+
function onProfileHandler(): void {
117+
// Check if the profile exists and return it the behavior has to be idempotent as users may call transaction.finish multiple times.
118+
if (!transaction) {
119+
return;
120+
}
121+
if (processedProfile) {
122+
if (__DEBUG_BUILD__) {
123+
logger.log(
124+
'[Profiling] profile for:',
125+
transaction.name || transaction.description,
126+
'already exists, returning early',
127+
);
128+
}
129+
return;
130+
}
131+
132+
profiler
133+
.stop()
134+
.then((p: JSSelfProfile): void => {
135+
if (maxDurationTimeoutID) {
136+
WINDOW.clearTimeout(maxDurationTimeoutID);
137+
maxDurationTimeoutID = undefined;
138+
}
139+
140+
if (__DEBUG_BUILD__) {
141+
logger.log(`[Profiling] stopped profiling of transaction: ${transaction.name || transaction.description}`);
142+
}
143+
144+
// In case of an overlapping transaction, stopProfiling may return null and silently ignore the overlapping profile.
145+
if (!p) {
146+
if (__DEBUG_BUILD__) {
147+
logger.log(
148+
`[Profiling] profiler returned null profile for: ${transaction.name || transaction.description}`,
149+
'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started',
150+
);
151+
}
152+
return;
153+
}
154+
155+
// If a profile has less than 2 samples, it is not useful and should be discarded.
156+
if (p.samples.length < 2) {
157+
return;
158+
}
159+
160+
processedProfile = { ...p, profile_id: profileId };
161+
sendProfile(profileId, processedProfile);
162+
})
163+
.catch(error => {
164+
if (__DEBUG_BUILD__) {
165+
logger.log('[Profiling] error while stopping profiler:', error);
166+
}
167+
return null;
168+
});
169+
}
170+
171+
// Enqueue a timeout to prevent profiles from running over max duration.
172+
let maxDurationTimeoutID: number | undefined = WINDOW.setTimeout(() => {
173+
if (__DEBUG_BUILD__) {
174+
logger.log(
175+
'[Profiling] max profile duration elapsed, stopping profiling for:',
176+
transaction.name || transaction.description,
177+
);
178+
}
179+
void onProfileHandler();
180+
}, MAX_PROFILE_DURATION_MS);
181+
182+
// We need to reference the original finish call to avoid creating an infinite loop
183+
const originalFinish = transaction.finish.bind(transaction);
184+
185+
/**
186+
* Wraps startTransaction and stopTransaction with profiling related logic.
187+
* startProfiling is called after the call to startTransaction in order to avoid our own code from
188+
* being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction.
189+
*/
190+
function profilingWrappedTransactionFinish(): Promise<Transaction> {
191+
if (!transaction) {
192+
return originalFinish();
193+
}
194+
// onProfileHandler should always return the same profile even if this is called multiple times.
195+
// Always call onProfileHandler to ensure stopProfiling is called and the timeout is cleared.
196+
onProfileHandler();
197+
198+
// Set profile context
199+
transaction.setContext('profile', { profile_id: profileId });
200+
201+
return originalFinish();
202+
}
203+
204+
transaction.finish = profilingWrappedTransactionFinish;
205+
return transaction;
206+
}
207+
208+
/**
209+
* Wraps startTransaction with profiling logic. This is done automatically by the profiling integration.
210+
*/
211+
function __PRIVATE__wrapStartTransactionWithProfiling(startTransaction: StartTransaction): StartTransaction {
212+
return function wrappedStartTransaction(
213+
this: Hub,
214+
transactionContext: TransactionContext,
215+
customSamplingContext?: CustomSamplingContext,
216+
): Transaction | undefined {
217+
const transaction: Transaction | undefined = startTransaction.call(this, transactionContext, customSamplingContext);
218+
if (transaction === undefined) {
219+
if (__DEBUG_BUILD__) {
220+
logger.log('[Profiling] Transaction is undefined, skipping profiling');
221+
}
222+
return transaction;
223+
}
224+
225+
return wrapTransactionWithProfiling(transaction);
226+
};
227+
}
228+
229+
/**
230+
* Patches startTransaction and stopTransaction with profiling logic.
231+
*/
232+
export function addProfilingExtensionMethods(): void {
233+
const carrier = getMainCarrier();
234+
if (!carrier.__SENTRY__) {
235+
if (__DEBUG_BUILD__) {
236+
logger.log("[Profiling] Can't find main carrier, profiling won't work.");
237+
}
238+
return;
239+
}
240+
carrier.__SENTRY__.extensions = carrier.__SENTRY__.extensions || {};
241+
242+
if (!carrier.__SENTRY__.extensions['startTransaction']) {
243+
if (__DEBUG_BUILD__) {
244+
logger.log(
245+
'[Profiling] startTransaction does not exists, profiling will not work. Make sure you import @sentry/tracing package before @sentry/profiling-node as import order matters.',
246+
);
247+
}
248+
return;
249+
}
250+
251+
if (__DEBUG_BUILD__) {
252+
logger.log('[Profiling] startTransaction exists, patching it with profiling functionality...');
253+
}
254+
255+
carrier.__SENTRY__.extensions['startTransaction'] = __PRIVATE__wrapStartTransactionWithProfiling(
256+
// This is already patched by sentry/tracing, we are going to re-patch it...
257+
carrier.__SENTRY__.extensions['startTransaction'] as StartTransaction,
258+
);
259+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type { Event, EventProcessor, Integration } from '@sentry/types';
2+
import { logger } from '@sentry/utils';
3+
4+
import { PROFILING_EVENT_CACHE } from './cache';
5+
import { addProfilingExtensionMethods } from './hubextensions';
6+
7+
/**
8+
* Browser profiling integration. Stores any event that has contexts["profile"]["profile_id"]
9+
* This exists because we do not want to await async profiler.stop calls as transaction.finish is called
10+
* in a synchronous context. Instead, we handle sending the profile async from the promise callback and
11+
* rely on being able to pull the event from the cache when we need to construct the envelope. This makes the
12+
* integration less reliable as we might be dropping profiles when the cache is full.
13+
*
14+
* @experimental
15+
*/
16+
export class BrowserProfilingIntegration implements Integration {
17+
public readonly name: string = 'BrowserProfilingIntegration';
18+
19+
/**
20+
* @inheritDoc
21+
*/
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+
}
32+
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+
}
45+
46+
return event;
47+
}
48+
}

0 commit comments

Comments
 (0)