utils.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. import { getClient, DEFAULT_ENVIRONMENT } from '@sentry/core';
  2. import { forEachEnvelopeItem, logger, uuid4, GLOBAL_OBJ, browserPerformanceTimeOrigin } from '@sentry/utils';
  3. import { DEBUG_BUILD } from '../debug-build.js';
  4. import { WINDOW } from '../helpers.js';
  5. /* eslint-disable max-lines */
  6. const MS_TO_NS = 1e6;
  7. // Use 0 as main thread id which is identical to threadId in node:worker_threads
  8. // where main logs 0 and workers seem to log in increments of 1
  9. const THREAD_ID_STRING = String(0);
  10. const THREAD_NAME = 'main';
  11. // Machine properties (eval only once)
  12. let OS_PLATFORM = '';
  13. let OS_PLATFORM_VERSION = '';
  14. let OS_ARCH = '';
  15. let OS_BROWSER = (WINDOW.navigator && WINDOW.navigator.userAgent) || '';
  16. let OS_MODEL = '';
  17. const OS_LOCALE =
  18. (WINDOW.navigator && WINDOW.navigator.language) ||
  19. (WINDOW.navigator && WINDOW.navigator.languages && WINDOW.navigator.languages[0]) ||
  20. '';
  21. function isUserAgentData(data) {
  22. return typeof data === 'object' && data !== null && 'getHighEntropyValues' in data;
  23. }
  24. // @ts-expect-error userAgentData is not part of the navigator interface yet
  25. const userAgentData = WINDOW.navigator && WINDOW.navigator.userAgentData;
  26. if (isUserAgentData(userAgentData)) {
  27. userAgentData
  28. .getHighEntropyValues(['architecture', 'model', 'platform', 'platformVersion', 'fullVersionList'])
  29. .then((ua) => {
  30. OS_PLATFORM = ua.platform || '';
  31. OS_ARCH = ua.architecture || '';
  32. OS_MODEL = ua.model || '';
  33. OS_PLATFORM_VERSION = ua.platformVersion || '';
  34. if (ua.fullVersionList && ua.fullVersionList.length > 0) {
  35. const firstUa = ua.fullVersionList[ua.fullVersionList.length - 1];
  36. OS_BROWSER = `${firstUa.brand} ${firstUa.version}`;
  37. }
  38. })
  39. .catch(e => void e);
  40. }
  41. function isProcessedJSSelfProfile(profile) {
  42. return !('thread_metadata' in profile);
  43. }
  44. // Enriches the profile with threadId of the current thread.
  45. // This is done in node as we seem to not be able to get the info from C native code.
  46. /**
  47. *
  48. */
  49. function enrichWithThreadInformation(profile) {
  50. if (!isProcessedJSSelfProfile(profile)) {
  51. return profile;
  52. }
  53. return convertJSSelfProfileToSampledFormat(profile);
  54. }
  55. // Profile is marked as optional because it is deleted from the metadata
  56. // by the integration before the event is processed by other integrations.
  57. function getTraceId(event) {
  58. const traceId = event && event.contexts && event.contexts['trace'] && event.contexts['trace']['trace_id'];
  59. // Log a warning if the profile has an invalid traceId (should be uuidv4).
  60. // All profiles and transactions are rejected if this is the case and we want to
  61. // warn users that this is happening if they enable debug flag
  62. if (typeof traceId === 'string' && traceId.length !== 32) {
  63. if (DEBUG_BUILD) {
  64. logger.log(`[Profiling] Invalid traceId: ${traceId} on profiled event`);
  65. }
  66. }
  67. if (typeof traceId !== 'string') {
  68. return '';
  69. }
  70. return traceId;
  71. }
  72. /**
  73. * Creates a profiling event envelope from a Sentry event. If profile does not pass
  74. * validation, returns null.
  75. * @param event
  76. * @param dsn
  77. * @param metadata
  78. * @param tunnel
  79. * @returns {EventEnvelope | null}
  80. */
  81. /**
  82. * Creates a profiling event envelope from a Sentry event.
  83. */
  84. function createProfilePayload(
  85. profile_id,
  86. start_timestamp,
  87. processed_profile,
  88. event,
  89. ) {
  90. if (event.type !== 'transaction') {
  91. // createProfilingEventEnvelope should only be called for transactions,
  92. // we type guard this behavior with isProfiledTransactionEvent.
  93. throw new TypeError('Profiling events may only be attached to transactions, this should never occur.');
  94. }
  95. if (processed_profile === undefined || processed_profile === null) {
  96. throw new TypeError(
  97. `Cannot construct profiling event envelope without a valid profile. Got ${processed_profile} instead.`,
  98. );
  99. }
  100. const traceId = getTraceId(event);
  101. const enrichedThreadProfile = enrichWithThreadInformation(processed_profile);
  102. const transactionStartMs = start_timestamp
  103. ? start_timestamp
  104. : typeof event.start_timestamp === 'number'
  105. ? event.start_timestamp * 1000
  106. : Date.now();
  107. const transactionEndMs = typeof event.timestamp === 'number' ? event.timestamp * 1000 : Date.now();
  108. const profile = {
  109. event_id: profile_id,
  110. timestamp: new Date(transactionStartMs).toISOString(),
  111. platform: 'javascript',
  112. version: '1',
  113. release: event.release || '',
  114. environment: event.environment || DEFAULT_ENVIRONMENT,
  115. runtime: {
  116. name: 'javascript',
  117. version: WINDOW.navigator.userAgent,
  118. },
  119. os: {
  120. name: OS_PLATFORM,
  121. version: OS_PLATFORM_VERSION,
  122. build_number: OS_BROWSER,
  123. },
  124. device: {
  125. locale: OS_LOCALE,
  126. model: OS_MODEL,
  127. manufacturer: OS_BROWSER,
  128. architecture: OS_ARCH,
  129. is_emulator: false,
  130. },
  131. debug_meta: {
  132. images: applyDebugMetadata(processed_profile.resources),
  133. },
  134. profile: enrichedThreadProfile,
  135. transactions: [
  136. {
  137. name: event.transaction || '',
  138. id: event.event_id || uuid4(),
  139. trace_id: traceId,
  140. active_thread_id: THREAD_ID_STRING,
  141. relative_start_ns: '0',
  142. relative_end_ns: ((transactionEndMs - transactionStartMs) * 1e6).toFixed(0),
  143. },
  144. ],
  145. };
  146. return profile;
  147. }
  148. /*
  149. See packages/tracing-internal/src/browser/router.ts
  150. */
  151. /**
  152. *
  153. */
  154. function isAutomatedPageLoadTransaction(transaction) {
  155. return transaction.op === 'pageload';
  156. }
  157. /**
  158. * Converts a JSSelfProfile to a our sampled format.
  159. * Does not currently perform stack indexing.
  160. */
  161. function convertJSSelfProfileToSampledFormat(input) {
  162. let EMPTY_STACK_ID = undefined;
  163. let STACK_ID = 0;
  164. // Initialize the profile that we will fill with data
  165. const profile = {
  166. samples: [],
  167. stacks: [],
  168. frames: [],
  169. thread_metadata: {
  170. [THREAD_ID_STRING]: { name: THREAD_NAME },
  171. },
  172. };
  173. if (!input.samples.length) {
  174. return profile;
  175. }
  176. // We assert samples.length > 0 above and timestamp should always be present
  177. const start = input.samples[0].timestamp;
  178. // The JS SDK might change it's time origin based on some heuristic (see See packages/utils/src/time.ts)
  179. // when that happens, we need to ensure we are correcting the profile timings so the two timelines stay in sync.
  180. // Since JS self profiling time origin is always initialized to performance.timeOrigin, we need to adjust for
  181. // the drift between the SDK selected value and our profile time origin.
  182. const origin =
  183. typeof performance.timeOrigin === 'number' ? performance.timeOrigin : browserPerformanceTimeOrigin || 0;
  184. const adjustForOriginChange = origin - (browserPerformanceTimeOrigin || origin);
  185. for (let i = 0; i < input.samples.length; i++) {
  186. const jsSample = input.samples[i];
  187. // If sample has no stack, add an empty sample
  188. if (jsSample.stackId === undefined) {
  189. if (EMPTY_STACK_ID === undefined) {
  190. EMPTY_STACK_ID = STACK_ID;
  191. profile.stacks[EMPTY_STACK_ID] = [];
  192. STACK_ID++;
  193. }
  194. profile['samples'][i] = {
  195. // convert ms timestamp to ns
  196. elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0),
  197. stack_id: EMPTY_STACK_ID,
  198. thread_id: THREAD_ID_STRING,
  199. };
  200. continue;
  201. }
  202. let stackTop = input.stacks[jsSample.stackId];
  203. // Functions in top->down order (root is last)
  204. // We follow the stackTop.parentId trail and collect each visited frameId
  205. const stack = [];
  206. while (stackTop) {
  207. stack.push(stackTop.frameId);
  208. const frame = input.frames[stackTop.frameId];
  209. // If our frame has not been indexed yet, index it
  210. if (profile.frames[stackTop.frameId] === undefined) {
  211. profile.frames[stackTop.frameId] = {
  212. function: frame.name,
  213. abs_path: typeof frame.resourceId === 'number' ? input.resources[frame.resourceId] : undefined,
  214. lineno: frame.line,
  215. colno: frame.column,
  216. };
  217. }
  218. stackTop = stackTop.parentId === undefined ? undefined : input.stacks[stackTop.parentId];
  219. }
  220. const sample = {
  221. // convert ms timestamp to ns
  222. elapsed_since_start_ns: ((jsSample.timestamp + adjustForOriginChange - start) * MS_TO_NS).toFixed(0),
  223. stack_id: STACK_ID,
  224. thread_id: THREAD_ID_STRING,
  225. };
  226. profile['stacks'][STACK_ID] = stack;
  227. profile['samples'][i] = sample;
  228. STACK_ID++;
  229. }
  230. return profile;
  231. }
  232. /**
  233. * Adds items to envelope if they are not already present - mutates the envelope.
  234. * @param envelope
  235. */
  236. function addProfilesToEnvelope(envelope, profiles) {
  237. if (!profiles.length) {
  238. return envelope;
  239. }
  240. for (const profile of profiles) {
  241. envelope[1].push([{ type: 'profile' }, profile]);
  242. }
  243. return envelope;
  244. }
  245. /**
  246. * Finds transactions with profile_id context in the envelope
  247. * @param envelope
  248. * @returns
  249. */
  250. function findProfiledTransactionsFromEnvelope(envelope) {
  251. const events = [];
  252. forEachEnvelopeItem(envelope, (item, type) => {
  253. if (type !== 'transaction') {
  254. return;
  255. }
  256. for (let j = 1; j < item.length; j++) {
  257. const event = item[j] ;
  258. if (event && event.contexts && event.contexts['profile'] && event.contexts['profile']['profile_id']) {
  259. events.push(item[j] );
  260. }
  261. }
  262. });
  263. return events;
  264. }
  265. const debugIdStackParserCache = new WeakMap();
  266. /**
  267. * Applies debug meta data to an event from a list of paths to resources (sourcemaps)
  268. */
  269. function applyDebugMetadata(resource_paths) {
  270. const debugIdMap = GLOBAL_OBJ._sentryDebugIds;
  271. if (!debugIdMap) {
  272. return [];
  273. }
  274. const client = getClient();
  275. const options = client && client.getOptions();
  276. const stackParser = options && options.stackParser;
  277. if (!stackParser) {
  278. return [];
  279. }
  280. let debugIdStackFramesCache;
  281. const cachedDebugIdStackFrameCache = debugIdStackParserCache.get(stackParser);
  282. if (cachedDebugIdStackFrameCache) {
  283. debugIdStackFramesCache = cachedDebugIdStackFrameCache;
  284. } else {
  285. debugIdStackFramesCache = new Map();
  286. debugIdStackParserCache.set(stackParser, debugIdStackFramesCache);
  287. }
  288. // Build a map of filename -> debug_id
  289. const filenameDebugIdMap = Object.keys(debugIdMap).reduce((acc, debugIdStackTrace) => {
  290. let parsedStack;
  291. const cachedParsedStack = debugIdStackFramesCache.get(debugIdStackTrace);
  292. if (cachedParsedStack) {
  293. parsedStack = cachedParsedStack;
  294. } else {
  295. parsedStack = stackParser(debugIdStackTrace);
  296. debugIdStackFramesCache.set(debugIdStackTrace, parsedStack);
  297. }
  298. for (let i = parsedStack.length - 1; i >= 0; i--) {
  299. const stackFrame = parsedStack[i];
  300. const file = stackFrame && stackFrame.filename;
  301. if (stackFrame && file) {
  302. acc[file] = debugIdMap[debugIdStackTrace] ;
  303. break;
  304. }
  305. }
  306. return acc;
  307. }, {});
  308. const images = [];
  309. for (const path of resource_paths) {
  310. if (path && filenameDebugIdMap[path]) {
  311. images.push({
  312. type: 'sourcemap',
  313. code_file: path,
  314. debug_id: filenameDebugIdMap[path] ,
  315. });
  316. }
  317. }
  318. return images;
  319. }
  320. /**
  321. * Checks the given sample rate to make sure it is valid type and value (a boolean, or a number between 0 and 1).
  322. */
  323. function isValidSampleRate(rate) {
  324. // we need to check NaN explicitly because it's of type 'number' and therefore wouldn't get caught by this typecheck
  325. if ((typeof rate !== 'number' && typeof rate !== 'boolean') || (typeof rate === 'number' && isNaN(rate))) {
  326. DEBUG_BUILD &&
  327. logger.warn(
  328. `[Profiling] Invalid sample rate. Sample rate must be a boolean or a number between 0 and 1. Got ${JSON.stringify(
  329. rate,
  330. )} of type ${JSON.stringify(typeof rate)}.`,
  331. );
  332. return false;
  333. }
  334. // Boolean sample rates are always valid
  335. if (rate === true || rate === false) {
  336. return true;
  337. }
  338. // in case sampleRate is a boolean, it will get automatically cast to 1 if it's true and 0 if it's false
  339. if (rate < 0 || rate > 1) {
  340. DEBUG_BUILD && logger.warn(`[Profiling] Invalid sample rate. Sample rate must be between 0 and 1. Got ${rate}.`);
  341. return false;
  342. }
  343. return true;
  344. }
  345. function isValidProfile(profile) {
  346. if (profile.samples.length < 2) {
  347. if (DEBUG_BUILD) {
  348. // Log a warning if the profile has less than 2 samples so users can know why
  349. // they are not seeing any profiling data and we cant avoid the back and forth
  350. // of asking them to provide us with a dump of the profile data.
  351. logger.log('[Profiling] Discarding profile because it contains less than 2 samples');
  352. }
  353. return false;
  354. }
  355. if (!profile.frames.length) {
  356. if (DEBUG_BUILD) {
  357. logger.log('[Profiling] Discarding profile because it contains no frames');
  358. }
  359. return false;
  360. }
  361. return true;
  362. }
  363. // Keep a flag value to avoid re-initializing the profiler constructor. If it fails
  364. // once, it will always fail and this allows us to early return.
  365. let PROFILING_CONSTRUCTOR_FAILED = false;
  366. const MAX_PROFILE_DURATION_MS = 30000;
  367. /**
  368. * Check if profiler constructor is available.
  369. * @param maybeProfiler
  370. */
  371. function isJSProfilerSupported(maybeProfiler) {
  372. return typeof maybeProfiler === 'function';
  373. }
  374. /**
  375. * Starts the profiler and returns the profiler instance.
  376. */
  377. function startJSSelfProfile() {
  378. // Feature support check first
  379. const JSProfilerConstructor = WINDOW.Profiler;
  380. if (!isJSProfilerSupported(JSProfilerConstructor)) {
  381. if (DEBUG_BUILD) {
  382. logger.log(
  383. '[Profiling] Profiling is not supported by this browser, Profiler interface missing on window object.',
  384. );
  385. }
  386. return;
  387. }
  388. // From initial testing, it seems that the minimum value for sampleInterval is 10ms.
  389. const samplingIntervalMS = 10;
  390. // Start the profiler
  391. const maxSamples = Math.floor(MAX_PROFILE_DURATION_MS / samplingIntervalMS);
  392. // Attempt to initialize the profiler constructor, if it fails, we disable profiling for the current user session.
  393. // This is likely due to a missing 'Document-Policy': 'js-profiling' header. We do not want to throw an error if this happens
  394. // as we risk breaking the user's application, so just disable profiling and log an error.
  395. try {
  396. return new JSProfilerConstructor({ sampleInterval: samplingIntervalMS, maxBufferSize: maxSamples });
  397. } catch (e) {
  398. if (DEBUG_BUILD) {
  399. logger.log(
  400. "[Profiling] Failed to initialize the Profiling constructor, this is likely due to a missing 'Document-Policy': 'js-profiling' header.",
  401. );
  402. logger.log('[Profiling] Disabling profiling for current user session.');
  403. }
  404. PROFILING_CONSTRUCTOR_FAILED = true;
  405. }
  406. return;
  407. }
  408. /**
  409. * Determine if a profile should be profiled.
  410. */
  411. function shouldProfileTransaction(transaction) {
  412. // If constructor failed once, it will always fail, so we can early return.
  413. if (PROFILING_CONSTRUCTOR_FAILED) {
  414. if (DEBUG_BUILD) {
  415. logger.log('[Profiling] Profiling has been disabled for the duration of the current user session.');
  416. }
  417. return false;
  418. }
  419. if (!transaction.isRecording()) {
  420. if (DEBUG_BUILD) {
  421. logger.log('[Profiling] Discarding profile because transaction was not sampled.');
  422. }
  423. return false;
  424. }
  425. const client = getClient();
  426. const options = client && client.getOptions();
  427. if (!options) {
  428. DEBUG_BUILD && logger.log('[Profiling] Profiling disabled, no options found.');
  429. return false;
  430. }
  431. // @ts-expect-error profilesSampleRate is not part of the browser options yet
  432. const profilesSampleRate = options.profilesSampleRate;
  433. // Since this is coming from the user (or from a function provided by the user), who knows what we might get. (The
  434. // only valid values are booleans or numbers between 0 and 1.)
  435. if (!isValidSampleRate(profilesSampleRate)) {
  436. DEBUG_BUILD && logger.warn('[Profiling] Discarding profile because of invalid sample rate.');
  437. return false;
  438. }
  439. // if the function returned 0 (or false), or if `profileSampleRate` is 0, it's a sign the profile should be dropped
  440. if (!profilesSampleRate) {
  441. DEBUG_BUILD &&
  442. logger.log(
  443. '[Profiling] Discarding profile because a negative sampling decision was inherited or profileSampleRate is set to 0',
  444. );
  445. return false;
  446. }
  447. // Now we roll the dice. Math.random is inclusive of 0, but not of 1, so strict < is safe here. In case sampleRate is
  448. // a boolean, the < comparison will cause it to be automatically cast to 1 if it's true and 0 if it's false.
  449. const sampled = profilesSampleRate === true ? true : Math.random() < profilesSampleRate;
  450. // Check if we should sample this profile
  451. if (!sampled) {
  452. DEBUG_BUILD &&
  453. logger.log(
  454. `[Profiling] Discarding profile because it's not included in the random sample (sampling rate = ${Number(
  455. profilesSampleRate,
  456. )})`,
  457. );
  458. return false;
  459. }
  460. return true;
  461. }
  462. /**
  463. * Creates a profiling envelope item, if the profile does not pass validation, returns null.
  464. * @param event
  465. * @returns {Profile | null}
  466. */
  467. function createProfilingEvent(
  468. profile_id,
  469. start_timestamp,
  470. profile,
  471. event,
  472. ) {
  473. if (!isValidProfile(profile)) {
  474. return null;
  475. }
  476. return createProfilePayload(profile_id, start_timestamp, profile, event);
  477. }
  478. const PROFILE_MAP = new Map();
  479. /**
  480. *
  481. */
  482. function getActiveProfilesCount() {
  483. return PROFILE_MAP.size;
  484. }
  485. /**
  486. * Retrieves profile from global cache and removes it.
  487. */
  488. function takeProfileFromGlobalCache(profile_id) {
  489. const profile = PROFILE_MAP.get(profile_id);
  490. if (profile) {
  491. PROFILE_MAP.delete(profile_id);
  492. }
  493. return profile;
  494. }
  495. /**
  496. * Adds profile to global cache and evicts the oldest profile if the cache is full.
  497. */
  498. function addProfileToGlobalCache(profile_id, profile) {
  499. PROFILE_MAP.set(profile_id, profile);
  500. if (PROFILE_MAP.size > 30) {
  501. const last = PROFILE_MAP.keys().next().value;
  502. PROFILE_MAP.delete(last);
  503. }
  504. }
  505. export { MAX_PROFILE_DURATION_MS, addProfileToGlobalCache, addProfilesToEnvelope, applyDebugMetadata, convertJSSelfProfileToSampledFormat, createProfilePayload, createProfilingEvent, enrichWithThreadInformation, findProfiledTransactionsFromEnvelope, getActiveProfilesCount, isAutomatedPageLoadTransaction, isValidSampleRate, shouldProfileTransaction, startJSSelfProfile, takeProfileFromGlobalCache };
  506. //# sourceMappingURL=utils.js.map