UNPKG

@sentry/browser

Version:
770 lines (667 loc) 24.2 kB
import { GLOBAL_OBJ, spanToJSON, debug, getClient, forEachEnvelopeItem, uuid4, timestampInSeconds, DEFAULT_ENVIRONMENT, browserPerformanceTimeOrigin, getDebugImagesForResources } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build.js'; import { WINDOW } from '../helpers.js'; const MS_TO_NS = 1e6; // Checking if we are in Main or Worker thread: `self` (not `window`) is the `globalThis` in Web Workers and `importScripts` are only available in Web Workers const isMainThread = 'window' in GLOBAL_OBJ && GLOBAL_OBJ.window === GLOBAL_OBJ && typeof importScripts === 'undefined'; // Setting ID to 0 as we cannot get an ID from Web Workers const PROFILER_THREAD_ID_STRING = String(0); const PROFILER_THREAD_NAME = isMainThread ? 'main' : 'worker'; // We force make this optional to be on the safe side... const navigator = WINDOW.navigator ; // Machine properties (eval only once) let OS_PLATFORM = ''; let OS_PLATFORM_VERSION = ''; let OS_ARCH = ''; let OS_BROWSER = navigator?.userAgent || ''; let OS_MODEL = ''; const OS_LOCALE = navigator?.language || navigator?.languages?.[0] || ''; function isUserAgentData(data) { return typeof data === 'object' && data !== null && 'getHighEntropyValues' in data; } // @ts-expect-error userAgentData is not part of the navigator interface yet const userAgentData = navigator?.userAgentData; if (isUserAgentData(userAgentData)) { userAgentData .getHighEntropyValues(['architecture', 'model', 'platform', 'platformVersion', 'fullVersionList']) .then((ua) => { OS_PLATFORM = ua.platform || ''; OS_ARCH = ua.architecture || ''; OS_MODEL = ua.model || ''; OS_PLATFORM_VERSION = ua.platformVersion || ''; if (ua.fullVersionList?.length) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const firstUa = ua.fullVersionList[ua.fullVersionList.length - 1]; OS_BROWSER = `${firstUa.brand} ${firstUa.version}`; } }) .catch(e => void 0); } function isProcessedJSSelfProfile(profile) { return !('thread_metadata' in profile); } // Enriches the profile with threadId of the current thread. // This is done in node as we seem to not be able to get the info from C native code. /** * */ function enrichWithThreadInformation(profile) { if (!isProcessedJSSelfProfile(profile)) { return profile; } return convertJSSelfProfileToSampledFormat(profile); } // Profile is marked as optional because it is deleted from the metadata // by the integration before the event is processed by other integrations. function getTraceId(event) { const traceId = event.contexts?.trace?.trace_id; // Log a warning if the profile has an invalid traceId (should be uuidv4). // All profiles and transactions are rejected if this is the case and we want to // warn users that this is happening if they enable debug flag if (typeof traceId === 'string' && traceId.length !== 32) { if (DEBUG_BUILD) { debug.log(`[Profiling] Invalid traceId: ${traceId} on profiled event`); } } if (typeof traceId !== 'string') { return ''; } return traceId; } /** * Creates a profiling event envelope from a Sentry event. If profile does not pass * validation, returns null. * @param event * @param dsn * @param metadata * @param tunnel * @returns {EventEnvelope | null} */ /** * Creates a profiling event envelope from a Sentry event. */ function createProfilePayload( profile_id, start_timestamp, processed_profile, event, ) { if (event.type !== 'transaction') { // createProfilingEventEnvelope should only be called for transactions, // we type guard this behavior with isProfiledTransactionEvent. throw new TypeError('Profiling events may only be attached to transactions, this should never occur.'); } if (processed_profile === undefined || processed_profile === null) { throw new TypeError( `Cannot construct profiling event envelope without a valid profile. Got ${processed_profile} instead.`, ); } const traceId = getTraceId(event); const enrichedThreadProfile = enrichWithThreadInformation(processed_profile); const transactionStartMs = start_timestamp ? start_timestamp : typeof event.start_timestamp === 'number' ? event.start_timestamp * 1000 : timestampInSeconds() * 1000; const transactionEndMs = typeof event.timestamp === 'number' ? event.timestamp * 1000 : timestampInSeconds() * 1000; const profile = { event_id: profile_id, timestamp: new Date(transactionStartMs).toISOString(), platform: 'javascript', version: '1', release: event.release || '', environment: event.environment || DEFAULT_ENVIRONMENT, runtime: { name: 'javascript', version: WINDOW.navigator.userAgent, }, os: { name: OS_PLATFORM, version: OS_PLATFORM_VERSION, build_number: OS_BROWSER, }, device: { locale: OS_LOCALE, model: OS_MODEL, manufacturer: OS_BROWSER, architecture: OS_ARCH, is_emulator: false, }, debug_meta: { images: applyDebugMetadata(processed_profile.resources), }, profile: enrichedThreadProfile, transactions: [ { name: event.transaction || '', id: event.event_id || uuid4(), trace_id: traceId, active_thread_id: PROFILER_THREAD_ID_STRING, relative_start_ns: '0', relative_end_ns: ((transactionEndMs - transactionStartMs) * 1e6).toFixed(0), }, ], }; return profile; } /** * Create a profile chunk envelope item */ function createProfileChunkPayload( jsSelfProfile, client, profilerId, ) { // only == to catch null and undefined if (jsSelfProfile == null) { throw new TypeError( `Cannot construct profiling event envelope without a valid profile. Got ${jsSelfProfile} instead.`, ); } const continuousProfile = convertToContinuousProfile(jsSelfProfile); const options = client.getOptions(); const sdk = client.getSdkMetadata?.()?.sdk; return { chunk_id: uuid4(), client_sdk: { name: sdk?.name ?? 'sentry.javascript.browser', version: sdk?.version ?? '0.0.0', }, profiler_id: profilerId || uuid4(), platform: 'javascript', version: '2', release: options.release ?? '', environment: options.environment ?? 'production', debug_meta: { // function name obfuscation images: applyDebugMetadata(jsSelfProfile.resources), }, profile: continuousProfile, }; } /** * Validate a profile chunk against the Sample Format V2 requirements. * https://develop.sentry.dev/sdk/telemetry/profiles/sample-format-v2/ * - Presence of samples, stacks, frames * - Required metadata fields */ function validateProfileChunk(chunk) { try { // Required metadata if (!chunk || typeof chunk !== 'object') { return { reason: 'chunk is not an object' }; } // profiler_id and chunk_id must be 32 lowercase hex chars const isHex32 = (val) => typeof val === 'string' && /^[a-f0-9]{32}$/.test(val); if (!isHex32(chunk.profiler_id)) { return { reason: 'missing or invalid profiler_id' }; } if (!isHex32(chunk.chunk_id)) { return { reason: 'missing or invalid chunk_id' }; } if (!chunk.client_sdk) { return { reason: 'missing client_sdk metadata' }; } // Profile data must have frames, stacks, samples const profile = chunk.profile ; if (!profile) { return { reason: 'missing profile data' }; } if (!Array.isArray(profile.frames) || !profile.frames.length) { return { reason: 'profile has no frames' }; } if (!Array.isArray(profile.stacks) || !profile.stacks.length) { return { reason: 'profile has no stacks' }; } if (!Array.isArray(profile.samples) || !profile.samples.length) { return { reason: 'profile has no samples' }; } return { valid: true }; } catch (e) { return { reason: `unknown validation error: ${e}` }; } } /** * Convert from JSSelfProfile format to ContinuousThreadCpuProfile format. */ function convertToContinuousProfile(input ) { // Frames map 1:1 by index; fill only when present to avoid sparse writes const frames = []; for (let i = 0; i < input.frames.length; i++) { const frame = input.frames[i]; if (!frame) { continue; } frames[i] = { function: frame.name, abs_path: typeof frame.resourceId === 'number' ? input.resources[frame.resourceId] : undefined, lineno: frame.line, colno: frame.column, }; } // Build stacks by following parent links, top->down order (root last) const stacks = []; for (let i = 0; i < input.stacks.length; i++) { const stackHead = input.stacks[i]; if (!stackHead) { continue; } const list = []; let current = stackHead; while (current) { list.push(current.frameId); current = current.parentId === undefined ? undefined : input.stacks[current.parentId]; } stacks[i] = list; } // Align timestamps to SDK time origin to match span/event timelines const perfOrigin = browserPerformanceTimeOrigin(); const origin = typeof performance.timeOrigin === 'number' ? performance.timeOrigin : perfOrigin || 0; const adjustForOriginChange = origin - (perfOrigin || origin); const samples = []; for (let i = 0; i < input.samples.length; i++) { const sample = input.samples[i]; if (!sample) { continue; } // Convert ms to seconds epoch-based timestamp const timestampSeconds = (origin + (sample.timestamp - adjustForOriginChange)) / 1000; samples[i] = { stack_id: sample.stackId ?? 0, thread_id: PROFILER_THREAD_ID_STRING, timestamp: timestampSeconds, }; } return { frames, stacks, samples, thread_metadata: { [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME } }, }; } /* See packages/browser-utils/src/browser/router.ts */ /** * */ function isAutomatedPageLoadSpan(span) { return spanToJSON(span).op === 'pageload'; } /** * Converts a JSSelfProfile to a our sampled format. * Does not currently perform stack indexing. */ function convertJSSelfProfileToSampledFormat(input) { let EMPTY_STACK_ID = undefined; let STACK_ID = 0; // Initialize the profile that we will fill with data const profile = { samples: [], stacks: [], frames: [], thread_metadata: { [PROFILER_THREAD_ID_STRING]: { name: PROFILER_THREAD_NAME }, }, }; const firstSample = input.samples[0]; if (!firstSample) { return profile; } // We assert samples.length > 0 above and timestamp should always be present const start = firstSample.timestamp; // The JS SDK might change it's time origin based on some heuristic (see See packages/utils/src/time.ts) // when that happens, we need to ensure we are correcting the profile timings so the two timelines stay in sync. // Since JS self profiling time origin is always initialized to performance.timeOrigin, we need to adjust for // the drift between the SDK selected value and our profile time origin. const perfOrigin = browserPerformanceTimeOrigin(); const origin = typeof performance.timeOrigin === 'number' ? performance.timeOrigin : perfOrigin || 0; const adjustForOriginChange = origin - (perfOrigin || origin); input.samples.forEach((jsSample, i) => { // If sample has no stack, add an empty sample if (jsSample.stackId === undefined) { if (EMPTY_STACK_ID === undefined) { EMPTY_STACK_ID = STACK_ID; profile.stacks[EMPTY_STACK_ID] = []; STACK_ID++; } profile['samples'][i] = { // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: EMPTY_STACK_ID, thread_id: PROFILER_THREAD_ID_STRING, }; return; } let stackTop = input.stacks[jsSample.stackId]; // Functions in top->down order (root is last) // We follow the stackTop.parentId trail and collect each visited frameId const stack = []; while (stackTop) { stack.push(stackTop.frameId); const frame = input.frames[stackTop.frameId]; // If our frame has not been indexed yet, index it if (frame && profile.frames[stackTop.frameId] === undefined) { profile.frames[stackTop.frameId] = { function: frame.name, abs_path: typeof frame.resourceId === 'number' ? input.resources[frame.resourceId] : undefined, lineno: frame.line, colno: frame.column, }; } stackTop = stackTop.parentId === undefined ? undefined : input.stacks[stackTop.parentId]; } const sample = { // convert ms timestamp to ns elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0), stack_id: STACK_ID, thread_id: PROFILER_THREAD_ID_STRING, }; profile['stacks'][STACK_ID] = stack; profile['samples'][i] = sample; STACK_ID++; }); return profile; } /** * Adds items to envelope if they are not already present - mutates the envelope. * @param envelope */ function addProfilesToEnvelope(envelope, profiles) { if (!profiles.length) { return envelope; } for (const profile of profiles) { envelope[1].push([{ type: 'profile' }, profile]); } return envelope; } /** * Finds transactions with profile_id context in the envelope * @param envelope * @returns */ function findProfiledTransactionsFromEnvelope(envelope) { const events = []; forEachEnvelopeItem(envelope, (item, type) => { if (type !== 'transaction') { return; } for (let j = 1; j < item.length; j++) { const event = item[j] ; if (event?.contexts?.profile?.profile_id) { events.push(item[j] ); } } }); return events; } /** * Applies debug meta data to an event from a list of paths to resources (sourcemaps) */ function applyDebugMetadata(resource_paths) { const client = getClient(); const options = client?.getOptions(); const stackParser = options?.stackParser; if (!stackParser) { return []; } return getDebugImagesForResources(stackParser, resource_paths); } /** * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1). */ function isValidSampleRate(rate) { // we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck if ((typeof rate !== 'number' && typeof rate !== 'boolean') || (typeof rate === 'number' && isNaN(rate))) { DEBUG_BUILD && debug.warn( `[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify( rate, )} of type ${JSON.stringify(typeof rate)}.`, ); return false; } // Boolean sample rates are always valid if (rate === true || rate === false) { return true; } // in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false if (rate < 0 || rate > 1) { DEBUG_BUILD && debug.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`); return false; } return true; } function isValidProfile(profile) { if (profile.samples.length < 2) { if (DEBUG_BUILD) { // Log a warning if the profile has less than 2 samples so users can know why // they are not seeing any profiling data and we cant avoid the back and forth // of asking them to provide us with a dump of the profile data. debug.log('[Profiling] Discarding profile because it contains less than 2 samples'); } return false; } if (!profile.frames.length) { if (DEBUG_BUILD) { debug.log('[Profiling] Discarding profile because it contains no frames'); } return false; } return true; } // 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; const MAX_PROFILE_DURATION_MS = 30000; /** * Check if profiler constructor is available. * @param maybeProfiler */ function isJSProfilerSupported(maybeProfiler) { return typeof maybeProfiler === 'function'; } /** * Starts the profiler and returns the profiler instance. */ function startJSSelfProfile() { // Feature support check first const JSProfilerConstructor = WINDOW.Profiler; if (!isJSProfilerSupported(JSProfilerConstructor)) { if (DEBUG_BUILD) { debug.log('[Profiling] Profiling is not supported by this browser, Profiler interface missing on window object.'); } return; } // 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); // 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 { return new JSProfilerConstructor({ sampleInterval: samplingIntervalMS, maxBufferSize: maxSamples }); } catch (e) { if (DEBUG_BUILD) { debug.log( "[Profiling] Failed to initialize the Profiling constructor, this is likely due to a missing 'Document-Policy': 'js-profiling' header.", ); debug.log('[Profiling] Disabling profiling for current user session.'); } PROFILING_CONSTRUCTOR_FAILED = true; } return; } /** * Determine if a profile should be profiled. */ function shouldProfileSpanLegacy(span) { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { debug.log('[Profiling] Profiling has been disabled for the duration of the current user session.'); } return false; } if (!span.isRecording()) { DEBUG_BUILD && debug.log('[Profiling] Discarding profile because root span was not sampled.'); return false; } const client = getClient(); const options = client?.getOptions(); if (!options) { DEBUG_BUILD && debug.log('[Profiling] Profiling disabled, no options found.'); return false; } // eslint-disable-next-line deprecation/deprecation 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)) { DEBUG_BUILD && debug.warn('[Profiling] Discarding profile because of invalid sample rate.'); return false; } // if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped if (!profilesSampleRate) { DEBUG_BUILD && debug.log( '[Profiling] Discarding profile because a negative sampling decision was inherited or profileSampleRate is set to 0', ); return false; } // 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) { DEBUG_BUILD && debug.log( `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number( profilesSampleRate, )})`, ); return false; } return true; } /** * Determine if a profile should be created for the current session. */ function shouldProfileSession(options) { // If constructor failed once, it will always fail, so we can early return. if (PROFILING_CONSTRUCTOR_FAILED) { if (DEBUG_BUILD) { debug.log( '[Profiling] Profiling has been disabled for the duration of the current user session as the JS Profiler could not be started.', ); } return false; } if (options.profileLifecycle !== 'trace' && options.profileLifecycle !== 'manual') { DEBUG_BUILD && debug.warn('[Profiling] Session not sampled. Invalid `profileLifecycle` option.'); return false; } // Session sampling: profileSessionSampleRate gates whether profiling is enabled for this session const profileSessionSampleRate = options.profileSessionSampleRate; if (!isValidSampleRate(profileSessionSampleRate)) { DEBUG_BUILD && debug.warn('[Profiling] Discarding profile because of invalid profileSessionSampleRate.'); return false; } if (!profileSessionSampleRate) { DEBUG_BUILD && debug.log('[Profiling] Discarding profile because profileSessionSampleRate is not defined or set to 0'); return false; } return Math.random() <= profileSessionSampleRate; } /** * Checks if legacy profiling is configured. */ function hasLegacyProfiling(options) { // eslint-disable-next-line deprecation/deprecation return typeof options.profilesSampleRate !== 'undefined'; } /** * Creates a profiling envelope item, if the profile does not pass validation, returns null. * @param event * @returns {Profile | null} */ function createProfilingEvent( profile_id, start_timestamp, profile, event, ) { if (!isValidProfile(profile)) { return null; } return createProfilePayload(profile_id, start_timestamp, profile, event); } // TODO (v8): We need to obtain profile ids in @sentry-internal/tracing, // but we don't have access to this map because importing this map would // cause a circular dependency. We need to resolve this in v8. const PROFILE_MAP = new Map(); /** * */ function getActiveProfilesCount() { return PROFILE_MAP.size; } /** * Retrieves profile from global cache and removes it. */ function takeProfileFromGlobalCache(profile_id) { const profile = PROFILE_MAP.get(profile_id); if (profile) { PROFILE_MAP.delete(profile_id); } return profile; } /** * Adds profile to global cache and evicts the oldest profile if the cache is full. */ function addProfileToGlobalCache(profile_id, profile) { PROFILE_MAP.set(profile_id, profile); if (PROFILE_MAP.size > 30) { const last = PROFILE_MAP.keys().next().value; if (last !== undefined) { PROFILE_MAP.delete(last); } } } /** * Attaches the profiled thread information to the event's trace context. */ function attachProfiledThreadToEvent(event) { if (!event?.contexts?.profile) { return event; } if (!event.contexts) { return event; } // @ts-expect-error the trace fallback value is wrong, though it should never happen // and in case it does, we dont want to override whatever was passed initially. event.contexts.trace = { ...(event.contexts?.trace ?? {}), data: { ...(event.contexts?.trace?.data ?? {}), ['thread.id']: PROFILER_THREAD_ID_STRING, ['thread.name']: PROFILER_THREAD_NAME, }, }; // Attach thread info to individual spans so that spans can be associated with the profiled thread on the UI even if contexts are missing. event.spans?.forEach(span => { span.data = { ...(span.data || {}), ['thread.id']: PROFILER_THREAD_ID_STRING, ['thread.name']: PROFILER_THREAD_NAME, }; }); return event; } export { MAX_PROFILE_DURATION_MS, PROFILER_THREAD_ID_STRING, PROFILER_THREAD_NAME, addProfileToGlobalCache, addProfilesToEnvelope, applyDebugMetadata, attachProfiledThreadToEvent, convertJSSelfProfileToSampledFormat, createProfileChunkPayload, createProfilePayload, createProfilingEvent, enrichWithThreadInformation, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, hasLegacyProfiling, isAutomatedPageLoadSpan, isValidSampleRate, shouldProfileSession, shouldProfileSpanLegacy, startJSSelfProfile, takeProfileFromGlobalCache, validateProfileChunk }; //# sourceMappingURL=utils.js.map