import { getCurrentHub } from '@sentry/core'; import { logger, uuid4 } from '@sentry/utils'; import { WINDOW } from '../helpers.js'; import { isValidSampleRate, addProfileToMap } from './utils.js'; /* eslint-disable complexity */ const MAX_PROFILE_DURATION_MS = 30000; // Keep a flag value to avoid re-initializing the profiler constructor. If it fails // once, it will always fail and this allows us to early return. let PROFILING_CONSTRUCTOR_FAILED = false; /** * Check if profiler constructor is available. * @param maybeProfiler */ function isJSProfilerSupported(maybeProfiler) { return typeof maybeProfiler === 'function'; } /** * Safety wrapper for startTransaction for the unlikely case that transaction starts before tracing is imported - * if that happens we want to avoid throwing an error from profiling code. * see https://github.com/getsentry/sentry-javascript/issues/4731. * * @experimental */ function onProfilingStartRouteTransaction(transaction) { if (!transaction) { if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log('[Profiling] Transaction is undefined, skipping profiling'); } return transaction; } return wrapTransactionWithProfiling(transaction); } /** * Wraps startTransaction and stopTransaction with profiling related logic. * startProfiling is called after the call to startTransaction in order to avoid our own code from * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. */ function wrapTransactionWithProfiling(transaction) { // Feature support check first const JSProfilerConstructor = WINDOW.Profiler; if (!isJSProfilerSupported(JSProfilerConstructor)) { if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log( '[Profiling] Profiling is not supported by this browser, Profiler interface missing on window object.', ); } return transaction; } // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log('[Profiling] Profiling has been disabled for the duration of the current user session.'); } return transaction; } const client = getCurrentHub().getClient(); const options = client && client.getOptions(); if (!options) { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log('[Profiling] Profiling disabled, no options found.'); return transaction; } // @ts-ignore profilesSampleRate is not part of the browser options yet const profilesSampleRate = options.profilesSampleRate; // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The // only valid values are booleans or numbers between 0 and 1.) if (!isValidSampleRate(profilesSampleRate)) { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.warn('[Profiling] Discarding profile because of invalid sample rate.'); return transaction; } // if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped if (!profilesSampleRate) { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log( '[Profiling] Discarding profile because a negative sampling decision was inherited or profileSampleRate is set to 0', ); return transaction; } // Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is // a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false. const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate; // Check if we should sample this profile if (!sampled) { (typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__) && logger.log( `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( profilesSampleRate, )})`, ); return transaction; } // From initial testing, it seems that the minimum value for sampleInterval is 10ms. const samplingIntervalMS = 10; // Start the profiler const maxSamples = Math.floor(MAX_PROFILE_DURATION_MS / samplingIntervalMS); let profiler; // Attempt to initialize the profiler constructor, if it fails, we disable profiling for the current user session. // This is likely due to a missing 'Document-Policy': 'js-profiling' header. We do not want to throw an error if this happens // as we risk breaking the user's application, so just disable profiling and log an error. try { profiler = new JSProfilerConstructor({ sampleInterval: samplingIntervalMS, maxBufferSize: maxSamples }); } catch (e) { if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log( "[Profiling] Failed to initialize the Profiling constructor, this is likely due to a missing 'Document-Policy': 'js-profiling' header.", ); logger.log('[Profiling] Disabling profiling for current user session.'); } PROFILING_CONSTRUCTOR_FAILED = true; } // We failed to construct the profiler, fallback to original transaction - there is no need to log // anything as we already did that in the try/catch block. if (!profiler) { return transaction; } if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log(`[Profiling] started profiling transaction: ${transaction.name || transaction.description}`); } // We create "unique" transaction names to avoid concurrent transactions with same names // from being ignored by the profiler. From here on, only this transaction name should be used when // calling the profiler methods. Note: we log the original name to the user to avoid confusion. const profileId = uuid4(); /** * Idempotent handler for profile stop */ async function onProfileHandler() { // Check if the profile exists and return it the behavior has to be idempotent as users may call transaction.finish multiple times. if (!transaction) { return null; } // Satisfy the type checker, but profiler will always be defined here. if (!profiler) { return null; } // This is temporary - we will use the collected span data to evaluate // if deferring txn.finish until profiler resolves is a viable approach. const stopProfilerSpan = transaction.startChild({ description: 'profiler.stop', op: 'profiler' }); return profiler .stop() .then((p) => { stopProfilerSpan.finish(); if (maxDurationTimeoutID) { WINDOW.clearTimeout(maxDurationTimeoutID); maxDurationTimeoutID = undefined; } if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log(`[Profiling] stopped profiling of transaction: ${transaction.name || transaction.description}`); } // In case of an overlapping transaction, stopProfiling may return null and silently ignore the overlapping profile. if (!p) { if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log( `[Profiling] profiler returned null profile for: ${transaction.name || transaction.description}`, 'this may indicate an overlapping transaction or a call to stopProfiling with a profile title that was never started', ); } return null; } addProfileToMap(profileId, p); return null; }) .catch(error => { stopProfilerSpan.finish(); if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log('[Profiling] error while stopping profiler:', error); } return null; }); } // Enqueue a timeout to prevent profiles from running over max duration. let maxDurationTimeoutID = WINDOW.setTimeout(() => { if ((typeof __SENTRY_DEBUG__ === 'undefined' || __SENTRY_DEBUG__)) { logger.log( '[Profiling] max profile duration elapsed, stopping profiling for:', transaction.name || transaction.description, ); } // If the timeout exceeds, we want to stop profiling, but not finish the transaction void onProfileHandler(); }, MAX_PROFILE_DURATION_MS); // We need to reference the original finish call to avoid creating an infinite loop const originalFinish = transaction.finish.bind(transaction); /** * Wraps startTransaction and stopTransaction with profiling related logic. * startProfiling is called after the call to startTransaction in order to avoid our own code from * being profiled. Because of that same reason, stopProfiling is called before the call to stopTransaction. */ function profilingWrappedTransactionFinish() { if (!transaction) { return originalFinish(); } // onProfileHandler should always return the same profile even if this is called multiple times. // Always call onProfileHandler to ensure stopProfiling is called and the timeout is cleared. void onProfileHandler().then( () => { transaction.setContext('profile', { profile_id: profileId }); originalFinish(); }, () => { // If onProfileHandler fails, we still want to call the original finish method. originalFinish(); }, ); return transaction; } transaction.finish = profilingWrappedTransactionFinish; return transaction; } export { MAX_PROFILE_DURATION_MS, onProfilingStartRouteTransaction, wrapTransactionWithProfiling }; //# sourceMappingURL=hubextensions.js.map