diff --git a/packages/ember/.gitignore b/packages/ember/.gitignore index 07c47279da5c..dae425bddf96 100644 --- a/packages/ember/.gitignore +++ b/packages/ember/.gitignore @@ -35,3 +35,4 @@ index.d.ts runloop.d.ts types.d.ts +utils/*.d.ts diff --git a/packages/ember/addon/index.ts b/packages/ember/addon/index.ts index 2ee9a9e3728e..c6da31c431f0 100644 --- a/packages/ember/addon/index.ts +++ b/packages/ember/addon/index.ts @@ -119,3 +119,8 @@ export const instrumentRoutePerformance = (BaseRoute }; export * from '@sentry/browser'; + +/** + * Ember-specific browser tracing integration + */ +export { browserTracingIntegration } from './utils/browserTracingIntegration'; diff --git a/packages/ember/addon/instance-initializers/sentry-performance.ts b/packages/ember/addon/instance-initializers/sentry-performance.ts index 4c5491c6a5a4..1391db0355c6 100644 --- a/packages/ember/addon/instance-initializers/sentry-performance.ts +++ b/packages/ember/addon/instance-initializers/sentry-performance.ts @@ -1,40 +1,6 @@ /* eslint-disable max-lines */ import type ApplicationInstance from '@ember/application/instance'; -import { subscribe } from '@ember/instrumentation'; -import type Transition from '@ember/routing/-private/transition'; -import type RouterService from '@ember/routing/router-service'; -import { _backburner, run, scheduleOnce } from '@ember/runloop'; -import type { EmberRunQueues } from '@ember/runloop/-private/types'; -import { getOwnConfig, isTesting, macroCondition } from '@embroider/macros'; -import type { - BrowserClient, - startBrowserTracingNavigationSpan as startBrowserTracingNavigationSpanType, - startBrowserTracingPageLoadSpan as startBrowserTracingPageLoadSpanType, -} from '@sentry/browser'; -import { - getActiveSpan, - getClient, - SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, - SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, - startInactiveSpan, -} from '@sentry/browser'; -import type { Span } from '@sentry/core'; -import { addIntegration, browserPerformanceTimeOrigin, GLOBAL_OBJ, timestampInSeconds } from '@sentry/core'; -import type { ExtendedBackburner } from '@sentry/ember/runloop'; -import type { EmberRouterMain, EmberSentryConfig, GlobalConfig, OwnConfig } from '../types'; - -function getSentryConfig(): EmberSentryConfig { - const _global = GLOBAL_OBJ as typeof GLOBAL_OBJ & GlobalConfig; - _global.__sentryEmberConfig = _global.__sentryEmberConfig ?? {}; - const environmentConfig = getOwnConfig().sentryConfig; - if (!environmentConfig.sentry) { - environmentConfig.sentry = { - browserTracingOptions: {}, - }; - } - Object.assign(environmentConfig.sentry, _global.__sentryEmberConfig); - return environmentConfig; -} +import { instrumentForPerformance, getSentryConfig } from '../utils/performance'; export function initialize(appInstance: ApplicationInstance): void { // Disable in fastboot - we only want to run Sentry client-side @@ -47,468 +13,12 @@ export function initialize(appInstance: ApplicationInstance): void { if (config['disablePerformance']) { return; } - const performancePromise = instrumentForPerformance(appInstance); - if (macroCondition(isTesting())) { - (window as typeof window & { _sentryPerformanceLoad?: Promise })._sentryPerformanceLoad = performancePromise; - } -} - -function getBackburner(): Pick { - if (_backburner) { - return _backburner as unknown as Pick; - } - - if ((run as unknown as { backburner?: Pick }).backburner) { - return (run as unknown as { backburner: Pick }).backburner; - } - - return { - on() { - // noop - }, - off() { - // noop - }, - }; -} - -function getTransitionInformation( - transition: Transition | undefined, - router: RouterService, -): { fromRoute?: string; toRoute?: string } { - const fromRoute = transition?.from?.name; - const toRoute = transition?.to?.name || router.currentRouteName; - return { - fromRoute, - toRoute, - }; -} - -// Only exported for testing -export function _getLocationURL(location: EmberRouterMain['location']): string { - if (!location?.getURL || !location?.formatURL) { - return ''; - } - const url = location.formatURL(location.getURL()); - - // `implementation` is optional in Ember's predefined location types, so we also check if the URL starts with '#'. - if (location.implementation === 'hash' || url.startsWith('#')) { - return `${location.rootURL}${url}`; - } - return url; -} - -export function _instrumentEmberRouter( - routerService: RouterService, - routerMain: EmberRouterMain, - config: EmberSentryConfig, - startBrowserTracingPageLoadSpan: typeof startBrowserTracingPageLoadSpanType, - startBrowserTracingNavigationSpan: typeof startBrowserTracingNavigationSpanType, -): void { - const { disableRunloopPerformance } = config; - const location = routerMain.location; - let activeRootSpan: Span | undefined; - let transitionSpan: Span | undefined; - - // Maintaining backwards compatibility with config.browserTracingOptions, but passing it with Sentry options is preferred. - const browserTracingOptions = config.browserTracingOptions || config.sentry.browserTracingOptions || {}; - const url = _getLocationURL(location); - - const client = getClient(); - - if (!client) { - return; - } - - if (url && browserTracingOptions.instrumentPageLoad !== false) { - const routeInfo = routerService.recognize(url); - activeRootSpan = startBrowserTracingPageLoadSpan(client, { - name: `route:${routeInfo.name}`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.ember', - url, - toRoute: routeInfo.name, - }, - }); - } - - const finishActiveTransaction = (_: unknown, nextInstance: unknown): void => { - if (nextInstance) { - return; - } - activeRootSpan?.end(); - getBackburner().off('end', finishActiveTransaction); - }; - - if (browserTracingOptions.instrumentNavigation === false) { - return; - } - - routerService.on('routeWillChange', (transition: Transition) => { - const { fromRoute, toRoute } = getTransitionInformation(transition, routerService); - - // We want to ignore loading && error routes - if (transitionIsIntermediate(transition)) { - return; - } - - activeRootSpan?.end(); - - activeRootSpan = startBrowserTracingNavigationSpan(client, { - name: `route:${toRoute}`, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.ember', - fromRoute, - toRoute, - }, - }); - - transitionSpan = startInactiveSpan({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', - }, - op: 'ui.ember.transition', - name: `route:${fromRoute} -> route:${toRoute}`, - onlyIfParent: true, - }); - }); - - routerService.on('routeDidChange', transition => { - if (!transitionSpan || !activeRootSpan || transitionIsIntermediate(transition)) { - return; - } - transitionSpan.end(); - - if (disableRunloopPerformance) { - activeRootSpan.end(); - return; - } - - getBackburner().on('end', finishActiveTransaction); - }); -} - -function _instrumentEmberRunloop(config: EmberSentryConfig): void { - const { disableRunloopPerformance, minimumRunloopQueueDuration } = config; - if (disableRunloopPerformance) { - return; - } - - let currentQueueStart: number | undefined; - let currentQueueSpan: Span | undefined; - const instrumentedEmberQueues = [ - 'actions', - 'routerTransitions', - 'render', - 'afterRender', - 'destroy', - ] as EmberRunQueues[]; - - getBackburner().on('begin', (_: unknown, previousInstance: unknown) => { - if (previousInstance) { - return; - } - const activeSpan = getActiveSpan(); - if (!activeSpan) { - return; - } - if (currentQueueSpan) { - currentQueueSpan.end(); - } - currentQueueStart = timestampInSeconds(); - - const processQueue = (queue: EmberRunQueues): void => { - // Process this queue using the end of the previous queue. - if (currentQueueStart) { - const now = timestampInSeconds(); - const minQueueDuration = minimumRunloopQueueDuration ?? 5; - - if ((now - currentQueueStart) * 1000 >= minQueueDuration) { - startInactiveSpan({ - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', - }, - name: 'runloop', - op: `ui.ember.runloop.${queue}`, - startTime: currentQueueStart, - onlyIfParent: true, - })?.end(now); - } - currentQueueStart = undefined; - } - - // Setup for next queue - const stillActiveSpan = getActiveSpan(); - if (!stillActiveSpan) { - return; - } - currentQueueStart = timestampInSeconds(); - }; - - instrumentedEmberQueues.forEach(queue => { - scheduleOnce(queue, null, processQueue, queue); - }); - }); - getBackburner().on('end', (_: unknown, nextInstance: unknown) => { - if (nextInstance) { - return; - } - if (currentQueueSpan) { - currentQueueSpan.end(); - currentQueueSpan = undefined; - } - }); -} - -type Payload = { - containerKey: string; - initialRender: true; - object: string; -}; - -type RenderEntry = { - payload: Payload; - now: number; -}; - -interface RenderEntries { - [name: string]: RenderEntry; -} - -function processComponentRenderBefore(payload: Payload, beforeEntries: RenderEntries): void { - const info = { - payload, - now: timestampInSeconds(), - }; - beforeEntries[payload.object] = info; -} - -function processComponentRenderAfter( - payload: Payload, - beforeEntries: RenderEntries, - op: string, - minComponentDuration: number, -): void { - const begin = beforeEntries[payload.object]; - - if (!begin) { - return; - } - - const now = timestampInSeconds(); - const componentRenderDuration = now - begin.now; - - if (componentRenderDuration * 1000 >= minComponentDuration) { - startInactiveSpan({ - name: payload.containerKey || payload.object, - op, - startTime: begin.now, - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', - }, - onlyIfParent: true, - })?.end(now); - } -} - -function _instrumentComponents(config: EmberSentryConfig): void { - const { disableInstrumentComponents, minimumComponentRenderDuration, enableComponentDefinitions } = config; - if (disableInstrumentComponents) { - return; - } - - const minComponentDuration = minimumComponentRenderDuration ?? 2; - - const beforeEntries = {} as RenderEntries; - const beforeComponentDefinitionEntries = {} as RenderEntries; - - function _subscribeToRenderEvents(): void { - subscribe('render.component', { - before(_name: string, _timestamp: number, payload: Payload) { - processComponentRenderBefore(payload, beforeEntries); - }, - - after(_name: string, _timestamp: number, payload: Payload, _beganIndex: number) { - processComponentRenderAfter(payload, beforeEntries, 'ui.ember.component.render', minComponentDuration); - }, - }); - if (enableComponentDefinitions) { - subscribe('render.getComponentDefinition', { - before(_name: string, _timestamp: number, payload: Payload) { - processComponentRenderBefore(payload, beforeComponentDefinitionEntries); - }, - - after(_name: string, _timestamp: number, payload: Payload, _beganIndex: number) { - processComponentRenderAfter(payload, beforeComponentDefinitionEntries, 'ui.ember.component.definition', 0); - }, - }); - } - } - _subscribeToRenderEvents(); -} - -function _instrumentInitialLoad(config: EmberSentryConfig): void { - const startName = '@sentry/ember:initial-load-start'; - const endName = '@sentry/ember:initial-load-end'; - - const { HAS_PERFORMANCE, HAS_PERFORMANCE_TIMING } = _hasPerformanceSupport(); - - if (!HAS_PERFORMANCE) { - return; - } - - const { performance } = window; - - if (config.disableInitialLoadInstrumentation) { - performance.clearMarks(startName); - performance.clearMarks(endName); - return; - } - - const origin = browserPerformanceTimeOrigin(); - // Split performance check in two so clearMarks still happens even if timeOrigin isn't available. - if (!HAS_PERFORMANCE_TIMING || origin === undefined) { - return; - } - const measureName = '@sentry/ember:initial-load'; - - const startMarkExists = performance.getEntriesByName(startName).length > 0; - const endMarkExists = performance.getEntriesByName(endName).length > 0; - if (!startMarkExists || !endMarkExists) { - return; - } - - performance.measure(measureName, startName, endName); - const measures = performance.getEntriesByName(measureName); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const measure = measures[0]!; - - const startTime = (measure.startTime + origin) / 1000; - const endTime = startTime + measure.duration / 1000; - - startInactiveSpan({ - op: 'ui.ember.init', - name: 'init', - attributes: { - [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', - }, - startTime, - onlyIfParent: true, - })?.end(endTime); - performance.clearMarks(startName); - performance.clearMarks(endName); - - performance.clearMeasures(measureName); -} - -function _hasPerformanceSupport(): { HAS_PERFORMANCE: boolean; HAS_PERFORMANCE_TIMING: boolean } { - // TS says that all of these methods are always available, but some of them may not be supported in older browsers - // So we "pretend" they are all optional in order to be able to check this properly without TS complaining - const _performance = window.performance as { - clearMarks?: Performance['clearMarks']; - clearMeasures?: Performance['clearMeasures']; - measure?: Performance['measure']; - getEntriesByName?: Performance['getEntriesByName']; - }; - const HAS_PERFORMANCE = Boolean(_performance?.clearMarks && _performance.clearMeasures); - const HAS_PERFORMANCE_TIMING = Boolean( - _performance.measure && _performance.getEntriesByName && browserPerformanceTimeOrigin !== undefined, - ); - - return { - HAS_PERFORMANCE, - HAS_PERFORMANCE_TIMING, - }; -} - -export async function instrumentForPerformance(appInstance: ApplicationInstance): Promise { - const config = getSentryConfig(); - // Maintaining backwards compatibility with config.browserTracingOptions, but passing it with Sentry options is preferred. - const browserTracingOptions = config.browserTracingOptions || config.sentry.browserTracingOptions || {}; - - const { browserTracingIntegration, startBrowserTracingNavigationSpan, startBrowserTracingPageLoadSpan } = - await import('@sentry/browser'); - - const idleTimeout = config.transitionTimeout || 5000; - - const browserTracing = browserTracingIntegration({ - idleTimeout, - ...browserTracingOptions, - instrumentNavigation: false, - instrumentPageLoad: false, - }); - - const client = getClient(); - const isAlreadyInitialized = macroCondition(isTesting()) ? !!client?.getIntegrationByName('BrowserTracing') : false; - addIntegration(browserTracing); - - // We _always_ call this, as it triggers the page load & navigation spans - _instrumentNavigation(appInstance, config, startBrowserTracingPageLoadSpan, startBrowserTracingNavigationSpan); - - // Skip instrumenting the stuff below again in tests, as these are not reset between tests - if (isAlreadyInitialized) { - return; - } - - _instrumentEmberRunloop(config); - _instrumentComponents(config); - _instrumentInitialLoad(config); -} - -function _instrumentNavigation( - appInstance: ApplicationInstance, - config: EmberSentryConfig, - startBrowserTracingPageLoadSpan: typeof startBrowserTracingPageLoadSpanType, - startBrowserTracingNavigationSpan: typeof startBrowserTracingNavigationSpanType, -): void { - // eslint-disable-next-line ember/no-private-routing-service - const routerMain = appInstance.lookup('router:main') as EmberRouterMain; - let routerService = appInstance.lookup('service:router') as RouterService & { - externalRouter?: RouterService; - _hasMountedSentryPerformanceRouting?: boolean; - }; - - if (routerService.externalRouter) { - // Using ember-engines-router-service in an engine. - routerService = routerService.externalRouter; - } - if (routerService._hasMountedSentryPerformanceRouting) { - // Routing listens to route changes on the main router, and should not be initialized multiple times per page. - return; - } - if (!routerService.recognize) { - // Router is missing critical functionality to limit cardinality of the transaction names. - return; - } - - routerService._hasMountedSentryPerformanceRouting = true; - _instrumentEmberRouter( - routerService, - routerMain, - config, - startBrowserTracingPageLoadSpan, - startBrowserTracingNavigationSpan, - ); + // Run this in the next tick to ensure the ember router etc. is properly initialized + instrumentForPerformance(appInstance); } export default { initialize, + name: 'sentry-performance', }; - -function transitionIsIntermediate(transition: Transition): boolean { - // We want to use ignore, as this may actually be defined on new versions - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore This actually exists on newer versions - const isIntermediate: boolean | undefined = transition.isIntermediate; - - if (typeof isIntermediate === 'boolean') { - return isIntermediate; - } - - // For versions without this, we look if the route is a `.loading` or `.error` route - // This is not perfect and may false-positive in some cases, but it's the best we can do - return transition.to?.localName === 'loading' || transition.to?.localName === 'error'; -} diff --git a/packages/ember/addon/utils/browserTracingIntegration.ts b/packages/ember/addon/utils/browserTracingIntegration.ts new file mode 100644 index 000000000000..37fa6b2e40a7 --- /dev/null +++ b/packages/ember/addon/utils/browserTracingIntegration.ts @@ -0,0 +1,79 @@ +import type { BrowserClient } from '@sentry/browser'; +import { + browserTracingIntegration as originalBrowserTracingIntegration, + startBrowserTracingNavigationSpan, + startBrowserTracingPageLoadSpan, +} from '@sentry/browser'; +import type { Integration } from '@sentry/core'; +import type ApplicationInstance from '@ember/application/instance'; +import { instrumentEmberAppInstanceForPerformance } from './instrumentEmberAppInstanceForPerformance'; +import { instrumentGlobalsForPerformance } from './instrumentEmberGlobals'; +import { isTesting, macroCondition } from '@embroider/macros'; + +type EmberBrowserTracingIntegrationOptions = Parameters[0] & { + appInstance: ApplicationInstance; + disableRunloopPerformance?: boolean; + minimumRunloopQueueDuration?: number; + disableInstrumentComponents?: boolean; + minimumComponentRenderDuration?: number; + enableComponentDefinitions?: boolean; + disableInitialLoadInstrumentation?: boolean; +}; + +let _initialized = false; + +export function browserTracingIntegration(options: EmberBrowserTracingIntegrationOptions): Integration { + const { appInstance } = options; + + const instrumentNavigation = options.instrumentNavigation ?? true; + const instrumentPageLoad = options.instrumentPageLoad ?? true; + + const integration = originalBrowserTracingIntegration({ + ...options, + instrumentNavigation: false, + instrumentPageLoad: false, + }); + + const appInstancePerformanceConfig = { + disableRunloopPerformance: options.disableRunloopPerformance ?? false, + instrumentPageLoad, + instrumentNavigation, + }; + + const globalsPerformanceConfig = { + disableRunloopPerformance: options.disableRunloopPerformance ?? false, + minimumRunloopQueueDuration: options.minimumRunloopQueueDuration, + disableInstrumentComponents: options.disableInstrumentComponents ?? false, + minimumComponentRenderDuration: options.minimumComponentRenderDuration, + enableComponentDefinitions: options.enableComponentDefinitions ?? false, + disableInitialLoadInstrumentation: options.disableInitialLoadInstrumentation ?? false, + }; + + return { + ...integration, + afterAllSetup(client: BrowserClient) { + integration.afterAllSetup(client); + + // Run this in the next tick to ensure the ember router etc. is properly initialized + setTimeout(() => { + instrumentEmberAppInstanceForPerformance( + client, + appInstance, + appInstancePerformanceConfig, + startBrowserTracingPageLoadSpan, + startBrowserTracingNavigationSpan, + ); + + // We only want to run this once in tests! + if (macroCondition(isTesting())) { + if (_initialized) { + return; + } + } + + instrumentGlobalsForPerformance(globalsPerformanceConfig); + _initialized = true; + }); + }, + }; +} diff --git a/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts new file mode 100644 index 000000000000..56bbfb303158 --- /dev/null +++ b/packages/ember/addon/utils/instrumentEmberAppInstanceForPerformance.ts @@ -0,0 +1,176 @@ +import type ApplicationInstance from '@ember/application/instance'; +import type Transition from '@ember/routing/-private/transition'; +import type RouterService from '@ember/routing/router-service'; +import type { + BrowserClient, + startBrowserTracingNavigationSpan as startBrowserTracingNavigationSpanType, + startBrowserTracingPageLoadSpan as startBrowserTracingPageLoadSpanType, +} from '@sentry/browser'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, startInactiveSpan } from '@sentry/browser'; +import type { Span } from '@sentry/core'; +import type { EmberRouterMain } from '../types'; +import { getBackburner } from './performance'; + +export function instrumentEmberAppInstanceForPerformance( + client: BrowserClient, + appInstance: ApplicationInstance, + config: { disableRunloopPerformance?: boolean; instrumentPageLoad?: boolean; instrumentNavigation?: boolean }, + startBrowserTracingPageLoadSpan: typeof startBrowserTracingPageLoadSpanType, + startBrowserTracingNavigationSpan: typeof startBrowserTracingNavigationSpanType, +): void { + // eslint-disable-next-line ember/no-private-routing-service + const routerMain = appInstance.lookup('router:main') as EmberRouterMain; + let routerService = appInstance.lookup('service:router') as RouterService & { + externalRouter?: RouterService; + _hasMountedSentryPerformanceRouting?: boolean; + }; + + if (routerService.externalRouter) { + // Using ember-engines-router-service in an engine. + routerService = routerService.externalRouter; + } + if (routerService._hasMountedSentryPerformanceRouting) { + // Routing listens to route changes on the main router, and should not be initialized multiple times per page. + return; + } + if (!routerService.recognize) { + // Router is missing critical functionality to limit cardinality of the transaction names. + return; + } + + routerService._hasMountedSentryPerformanceRouting = true; + _instrumentEmberRouter( + client, + routerService, + routerMain, + config, + startBrowserTracingPageLoadSpan, + startBrowserTracingNavigationSpan, + ); +} + +function getTransitionInformation( + transition: Transition | undefined, + router: RouterService, +): { fromRoute?: string; toRoute?: string } { + const fromRoute = transition?.from?.name; + const toRoute = transition?.to?.name || router.currentRouteName; + return { + fromRoute, + toRoute, + }; +} + +// Only exported for testing +export function _getLocationURL(location: EmberRouterMain['location']): string { + if (!location?.getURL || !location?.formatURL) { + return ''; + } + const url = location.formatURL(location.getURL()); + + // `implementation` is optional in Ember's predefined location types, so we also check if the URL starts with '#'. + if (location.implementation === 'hash' || url.startsWith('#')) { + return `${location.rootURL}${url}`; + } + return url; +} + +function _instrumentEmberRouter( + client: BrowserClient, + routerService: RouterService, + routerMain: EmberRouterMain, + config: { disableRunloopPerformance?: boolean; instrumentPageLoad?: boolean; instrumentNavigation?: boolean }, + startBrowserTracingPageLoadSpan: typeof startBrowserTracingPageLoadSpanType, + startBrowserTracingNavigationSpan: typeof startBrowserTracingNavigationSpanType, +): void { + const { disableRunloopPerformance, instrumentPageLoad, instrumentNavigation } = config; + const location = routerMain.location; + let activeRootSpan: Span | undefined; + let transitionSpan: Span | undefined; + + const url = _getLocationURL(location); + + if (url && instrumentPageLoad !== false) { + const routeInfo = routerService.recognize(url); + activeRootSpan = startBrowserTracingPageLoadSpan(client, { + name: `route:${routeInfo.name}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.ember', + url, + toRoute: routeInfo.name, + }, + }); + } + + const finishActiveTransaction = (_: unknown, nextInstance: unknown): void => { + if (nextInstance) { + return; + } + activeRootSpan?.end(); + getBackburner().off('end', finishActiveTransaction); + }; + + if (instrumentNavigation === false) { + return; + } + + routerService.on('routeWillChange', (transition: Transition) => { + const { fromRoute, toRoute } = getTransitionInformation(transition, routerService); + + // We want to ignore loading && error routes + if (transitionIsIntermediate(transition)) { + return; + } + + activeRootSpan?.end(); + + activeRootSpan = startBrowserTracingNavigationSpan(client, { + name: `route:${toRoute}`, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_SOURCE]: 'route', + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.ember', + fromRoute, + toRoute, + }, + }); + + transitionSpan = startInactiveSpan({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', + }, + op: 'ui.ember.transition', + name: `route:${fromRoute} -> route:${toRoute}`, + onlyIfParent: true, + }); + }); + + routerService.on('routeDidChange', transition => { + if (!transitionSpan || !activeRootSpan || transitionIsIntermediate(transition)) { + return; + } + transitionSpan.end(); + + if (disableRunloopPerformance) { + activeRootSpan.end(); + return; + } + + getBackburner().on('end', finishActiveTransaction); + }); +} + +function transitionIsIntermediate(transition: Transition): boolean { + // We want to use ignore, as this may actually be defined on new versions + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore This actually exists on newer versions + const isIntermediate: boolean | undefined = transition.isIntermediate; + + if (typeof isIntermediate === 'boolean') { + return isIntermediate; + } + + // For versions without this, we look if the route is a `.loading` or `.error` route + // This is not perfect and may false-positive in some cases, but it's the best we can do + return transition.to?.localName === 'loading' || transition.to?.localName === 'error'; +} diff --git a/packages/ember/addon/utils/instrumentEmberGlobals.ts b/packages/ember/addon/utils/instrumentEmberGlobals.ts new file mode 100644 index 000000000000..02e365e27c23 --- /dev/null +++ b/packages/ember/addon/utils/instrumentEmberGlobals.ts @@ -0,0 +1,265 @@ +import { subscribe } from '@ember/instrumentation'; +import { scheduleOnce } from '@ember/runloop'; +import type { EmberRunQueues } from '@ember/runloop/-private/types'; +import { getActiveSpan, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan } from '@sentry/browser'; +import type { Span } from '@sentry/core'; +import { browserPerformanceTimeOrigin, timestampInSeconds } from '@sentry/core'; +import { getBackburner } from './performance'; + +type Payload = { + containerKey: string; + initialRender: true; + object: string; +}; + +type RenderEntry = { + payload: Payload; + now: number; +}; + +interface RenderEntries { + [name: string]: RenderEntry; +} + +/** This is global, so should only be run once in tests! */ +export function instrumentGlobalsForPerformance(config: { + disableRunloopPerformance?: boolean; + minimumRunloopQueueDuration?: number; + disableInstrumentComponents?: boolean; + minimumComponentRenderDuration?: number; + enableComponentDefinitions?: boolean; + disableInitialLoadInstrumentation?: boolean; +}): void { + const { + disableRunloopPerformance, + minimumRunloopQueueDuration, + disableInstrumentComponents, + minimumComponentRenderDuration, + enableComponentDefinitions, + disableInitialLoadInstrumentation, + } = config; + + if (!disableRunloopPerformance) { + _instrumentEmberRunloop({ + minimumRunloopQueueDuration, + }); + } + if (!disableInstrumentComponents) { + _instrumentComponents({ + minimumComponentRenderDuration, + enableComponentDefinitions, + }); + } + if (!disableInitialLoadInstrumentation) { + _instrumentInitialLoad(); + } +} + +function _instrumentEmberRunloop(config: { minimumRunloopQueueDuration?: number }): void { + const { minimumRunloopQueueDuration } = config; + let currentQueueStart: number | undefined; + let currentQueueSpan: Span | undefined; + const instrumentedEmberQueues = [ + 'actions', + 'routerTransitions', + 'render', + 'afterRender', + 'destroy', + ] as EmberRunQueues[]; + + getBackburner().on('begin', (_: unknown, previousInstance: unknown) => { + if (previousInstance) { + return; + } + const activeSpan = getActiveSpan(); + if (!activeSpan) { + return; + } + if (currentQueueSpan) { + currentQueueSpan.end(); + } + currentQueueStart = timestampInSeconds(); + + const processQueue = (queue: EmberRunQueues): void => { + // Process this queue using the end of the previous queue. + if (currentQueueStart) { + const now = timestampInSeconds(); + const minQueueDuration = minimumRunloopQueueDuration ?? 5; + + if ((now - currentQueueStart) * 1000 >= minQueueDuration) { + startInactiveSpan({ + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', + }, + name: 'runloop', + op: `ui.ember.runloop.${queue}`, + startTime: currentQueueStart, + onlyIfParent: true, + })?.end(now); + } + currentQueueStart = undefined; + } + + // Setup for next queue + + const stillActiveSpan = getActiveSpan(); + if (!stillActiveSpan) { + return; + } + currentQueueStart = timestampInSeconds(); + }; + + instrumentedEmberQueues.forEach(queue => { + scheduleOnce(queue, null, processQueue, queue); + }); + }); + getBackburner().on('end', (_: unknown, nextInstance: unknown) => { + if (nextInstance) { + return; + } + if (currentQueueSpan) { + currentQueueSpan.end(); + currentQueueSpan = undefined; + } + }); +} + +function processComponentRenderBefore(payload: Payload, beforeEntries: RenderEntries): void { + const info = { + payload, + now: timestampInSeconds(), + }; + beforeEntries[payload.object] = info; +} + +function processComponentRenderAfter( + payload: Payload, + beforeEntries: RenderEntries, + op: string, + minComponentDuration: number, +): void { + const begin = beforeEntries[payload.object]; + + if (!begin) { + return; + } + + const now = timestampInSeconds(); + const componentRenderDuration = now - begin.now; + + if (componentRenderDuration * 1000 >= minComponentDuration) { + startInactiveSpan({ + name: payload.containerKey || payload.object, + op, + startTime: begin.now, + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', + }, + onlyIfParent: true, + })?.end(now); + } +} + +function _instrumentComponents(config: { + minimumComponentRenderDuration?: number; + enableComponentDefinitions?: boolean; +}): void { + const { minimumComponentRenderDuration, enableComponentDefinitions } = config; + + const minComponentDuration = minimumComponentRenderDuration ?? 2; + + const beforeEntries = {} as RenderEntries; + const beforeComponentDefinitionEntries = {} as RenderEntries; + + function _subscribeToRenderEvents(): void { + subscribe('render.component', { + before(_name: string, _timestamp: number, payload: Payload) { + processComponentRenderBefore(payload, beforeEntries); + }, + + after(_name: string, _timestamp: number, payload: Payload, _beganIndex: number) { + processComponentRenderAfter(payload, beforeEntries, 'ui.ember.component.render', minComponentDuration); + }, + }); + if (enableComponentDefinitions) { + subscribe('render.getComponentDefinition', { + before(_name: string, _timestamp: number, payload: Payload) { + processComponentRenderBefore(payload, beforeComponentDefinitionEntries); + }, + + after(_name: string, _timestamp: number, payload: Payload, _beganIndex: number) { + processComponentRenderAfter(payload, beforeComponentDefinitionEntries, 'ui.ember.component.definition', 0); + }, + }); + } + } + _subscribeToRenderEvents(); +} + +function _instrumentInitialLoad(): void { + const startName = '@sentry/ember:initial-load-start'; + const endName = '@sentry/ember:initial-load-end'; + + const { HAS_PERFORMANCE, HAS_PERFORMANCE_TIMING } = _hasPerformanceSupport(); + + if (!HAS_PERFORMANCE) { + return; + } + + const { performance } = window; + + const origin = browserPerformanceTimeOrigin(); + // Split performance check in two so clearMarks still happens even if timeOrigin isn't available. + if (!HAS_PERFORMANCE_TIMING || origin === undefined) { + return; + } + const measureName = '@sentry/ember:initial-load'; + + const startMarkExists = performance.getEntriesByName(startName).length > 0; + const endMarkExists = performance.getEntriesByName(endName).length > 0; + if (!startMarkExists || !endMarkExists) { + return; + } + + performance.measure(measureName, startName, endName); + const measures = performance.getEntriesByName(measureName); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const measure = measures[0]!; + + const startTime = (measure.startTime + origin) / 1000; + const endTime = startTime + measure.duration / 1000; + + startInactiveSpan({ + op: 'ui.ember.init', + name: 'init', + attributes: { + [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.ember', + }, + startTime, + onlyIfParent: true, + })?.end(endTime); + performance.clearMarks(startName); + performance.clearMarks(endName); + + performance.clearMeasures(measureName); +} + +function _hasPerformanceSupport(): { HAS_PERFORMANCE: boolean; HAS_PERFORMANCE_TIMING: boolean } { + // TS says that all of these methods are always available, but some of them may not be supported in older browsers + // So we "pretend" they are all optional in order to be able to check this properly without TS complaining + const _performance = window.performance as { + clearMarks?: Performance['clearMarks']; + clearMeasures?: Performance['clearMeasures']; + measure?: Performance['measure']; + getEntriesByName?: Performance['getEntriesByName']; + }; + const HAS_PERFORMANCE = Boolean(_performance?.clearMarks && _performance.clearMeasures); + const HAS_PERFORMANCE_TIMING = Boolean( + _performance.measure && _performance.getEntriesByName && browserPerformanceTimeOrigin !== undefined, + ); + + return { + HAS_PERFORMANCE, + HAS_PERFORMANCE_TIMING, + }; +} diff --git a/packages/ember/addon/utils/performance.ts b/packages/ember/addon/utils/performance.ts new file mode 100644 index 000000000000..07236c6150e4 --- /dev/null +++ b/packages/ember/addon/utils/performance.ts @@ -0,0 +1,82 @@ +/* eslint-disable max-lines */ +import type ApplicationInstance from '@ember/application/instance'; +import { _backburner, run } from '@ember/runloop'; +import { getOwnConfig, importSync, isTesting, macroCondition } from '@embroider/macros'; +import type { BrowserClient } from '@sentry/browser'; +import { getClient } from '@sentry/browser'; +import { addIntegration, GLOBAL_OBJ } from '@sentry/core'; +import type { ExtendedBackburner } from '@sentry/ember/runloop'; +import type { EmberSentryConfig, GlobalConfig, OwnConfig } from '../types'; +import type { browserTracingIntegration as browserTracingIntegrationType } from './browserTracingIntegration'; + +export function getSentryConfig(): EmberSentryConfig { + const _global = GLOBAL_OBJ as typeof GLOBAL_OBJ & GlobalConfig; + _global.__sentryEmberConfig = _global.__sentryEmberConfig ?? {}; + const environmentConfig = getOwnConfig().sentryConfig; + if (!environmentConfig.sentry) { + environmentConfig.sentry = { + browserTracingOptions: {}, + }; + } + Object.assign(environmentConfig.sentry, _global.__sentryEmberConfig); + return environmentConfig; +} + +export function getBackburner(): Pick { + if (_backburner) { + return _backburner as unknown as Pick; + } + + if ((run as unknown as { backburner?: Pick }).backburner) { + return (run as unknown as { backburner: Pick }).backburner; + } + + return { + on() { + // noop + }, + off() { + // noop + }, + }; +} + +/** + * Utility to register the browser tracing integration and instrument the app instance for performance. + */ +export function instrumentForPerformance(appInstance: ApplicationInstance): void { + const config = getSentryConfig(); + // Maintaining backwards compatibility with config.browserTracingOptions, but passing it with Sentry options is preferred. + const browserTracingOptions = config.browserTracingOptions || config.sentry.browserTracingOptions || {}; + + const { browserTracingIntegration } = importSync('./browserTracingIntegration') as { + browserTracingIntegration: typeof browserTracingIntegrationType; + }; + + const idleTimeout = config.transitionTimeout || 5000; + + const emberSpecificConfig = { + minimumRunloopQueueDuration: config.minimumRunloopQueueDuration, + minimumComponentRenderDuration: config.minimumComponentRenderDuration, + enableComponentDefinitions: config.enableComponentDefinitions, + disableInitialLoadInstrumentation: config.disableInitialLoadInstrumentation, + disableRunloopPerformance: config.disableRunloopPerformance, + disableInstrumentComponents: config.disableInstrumentComponents, + }; + + const browserTracing = browserTracingIntegration({ + appInstance, + idleTimeout, + ...browserTracingOptions, + ...emberSpecificConfig, + }); + + const client = getClient(); + const isAlreadyInitialized = macroCondition(isTesting()) ? client?.getIntegrationByName('BrowserTracing') : false; + addIntegration(browserTracing); + + // Ensure this is re-run in tests even if the integration is already initialized + if (isAlreadyInitialized && client) { + browserTracing.afterAllSetup?.(client); + } +} diff --git a/packages/ember/tests/helpers/setup-sentry.ts b/packages/ember/tests/helpers/setup-sentry.ts index 1c4de83681e6..2bb90ec72934 100644 --- a/packages/ember/tests/helpers/setup-sentry.ts +++ b/packages/ember/tests/helpers/setup-sentry.ts @@ -10,8 +10,7 @@ export type SentryTestContext = TestContext & { }; export function setupSentryTest(hooks: NestedHooks): void { - hooks.beforeEach(async function (this: SentryTestContext) { - await window._sentryPerformanceLoad; + hooks.beforeEach(function (this: SentryTestContext) { window._sentryTestEvents = []; const errorMessages: string[] = []; this.errorMessages = errorMessages; diff --git a/packages/ember/tests/test-helper.ts b/packages/ember/tests/test-helper.ts index e01f3ab50eba..b00310376e01 100644 --- a/packages/ember/tests/test-helper.ts +++ b/packages/ember/tests/test-helper.ts @@ -9,7 +9,6 @@ import setupSinon from 'ember-sinon-qunit'; declare global { interface Window { _sentryTestEvents: Sentry.Event[]; - _sentryPerformanceLoad?: Promise; } } diff --git a/packages/ember/tests/unit/instrument-router-location-test.ts b/packages/ember/tests/unit/instrument-router-location-test.ts index 16cc95da906a..a225089319af 100644 --- a/packages/ember/tests/unit/instrument-router-location-test.ts +++ b/packages/ember/tests/unit/instrument-router-location-test.ts @@ -1,5 +1,5 @@ import type { EmberRouterMain } from '@sentry/ember/addon/types'; -import { _getLocationURL } from '@sentry/ember/instance-initializers/sentry-performance'; +import { _getLocationURL } from '@sentry/ember/utils/instrumentEmberAppInstanceForPerformance'; import { setupTest } from 'ember-qunit'; import { module, test } from 'qunit'; import type { SentryTestContext } from '../helpers/setup-sentry';