Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ test('sends a pageload transaction with a parameterized URL', async ({ page }) =
trace: {
op: 'pageload',
origin: 'auto.pageload.ember',
data: {
'router.navigation.route.id': 'index',
},
},
},
transaction: 'route:index',
Expand Down Expand Up @@ -47,6 +50,9 @@ test('sends a navigation transaction with a parameterized URL', async ({ page })
trace: {
op: 'navigation',
origin: 'auto.navigation.ember',
data: {
'router.navigation.route.id': 'tracing',
},
},
},
transaction: 'route:tracing',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,52 @@ import { getSpanOp, waitForStreamedSpan } from '@sentry-internal/test-utils';
// Only the `ember-strict-resolver (streamed)` variant builds the app with `traceLifecycle: 'stream'`.
test.skip(process.env.E2E_TEST_TRACE_LIFECYCLE !== 'stream', 'requires the app built with span streaming');

test('adds the route ID to a streamed pageload', async ({ page }) => {
const pageloadSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => {
return (
span.is_segment &&
getSpanOp(span) === 'pageload' &&
span.attributes['sentry.origin']?.value === 'auto.pageload.ember'
);
});

await page.goto('/');

const pageloadSpan = await pageloadSpanPromise;

expect(pageloadSpan.attributes['router.navigation.route.id']).toEqual({ type: 'string', value: 'index' });
expect(pageloadSpan.name).toBe('route:index');
});

test('adds the route ID to a streamed navigation', async ({ page }) => {
const pageloadSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => {
return (
span.is_segment &&
getSpanOp(span) === 'pageload' &&
span.attributes['sentry.origin']?.value === 'auto.pageload.ember'
);
});

await page.goto('/');
await pageloadSpanPromise;

const navigationSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => {
return (
span.is_segment &&
getSpanOp(span) === 'navigation' &&
span.attributes['sentry.origin']?.value === 'auto.navigation.ember'
);
});

await page.getByText('Tracing').click();
await expect(page).toHaveURL(/\/tracing$/);

const navigationSpan = await navigationSpanPromise;

expect(navigationSpan.attributes['router.navigation.route.id']).toEqual({ type: 'string', value: 'tracing' });
expect(navigationSpan.name).toBe('route:tracing');
});

test('names the transition span with the low cardinality fallback', async ({ page }) => {
const transitionSpanPromise = waitForStreamedSpan('ember-strict-resolver', span => getSpanOp(span) === 'router');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from '@sentry/browser';
import { getAbsoluteUrl, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, WINDOW } from '@sentry/browser';
import {
ROUTER_NAVIGATION_ROUTE_ID,
SENTRY_SEGMENT_NAME_SOURCE,
SENTRY_OP,
URL_FULL,
Expand Down Expand Up @@ -75,6 +76,7 @@ export function instrumentEmberAppInstanceForPerformance(
attributes: {
[SENTRY_SEGMENT_NAME_SOURCE]: routeInfo ? 'route' : 'url',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.pageload.ember',
...(routeInfo?.name && { [ROUTER_NAVIGATION_ROUTE_ID]: routeInfo.name }),
...(url ? _getRouteUrlAttributes(client, url, routeInfo?.params) : {}),
toRoute: routeInfo?.name,
},
Expand Down Expand Up @@ -117,6 +119,7 @@ export function instrumentEmberAppInstanceForPerformance(
attributes: {
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.navigation.ember',
...(toRoute && { [ROUTER_NAVIGATION_ROUTE_ID]: toRoute }),
...urlAttributes,
fromRoute,
toRoute,
Expand All @@ -133,6 +136,7 @@ export function instrumentEmberAppInstanceForPerformance(
activeRootSpan.updateName(`route:${toRoute}`);
activeRootSpan.setAttributes({
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
...(toRoute && { [ROUTER_NAVIGATION_ROUTE_ID]: toRoute }),
..._getRouteUrlAttributes(client, url, routeInfo?.params),
toRoute: toRoute,
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import type ApplicationInstance from '@ember/application/instance';
import type Transition from '@ember/routing/transition';
import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes';
import { getCurrentScope, SentrySpan, spanToJSON, type Client, type StartSpanOptions } from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { instrumentEmberAppInstanceForPerformance } from '../src/utils/instrumentEmberAppInstanceForPerformance.ts';

function createRouterFixture() {
const handlers = new Map<string, (transition: Transition) => void>();
const router = {
recognize: vi.fn<() => { name: string; params: Record<string, string> } | undefined>().mockReturnValue({
name: 'index',
params: {},
}),
currentRouteName: undefined as string | undefined,
currentURL: '/',
on: (event: string, callback: (transition: Transition) => void) => handlers.set(event, callback),
};
const location = {
rootURL: '/',
getURL: () => '/',
formatURL: (url: string) => url,
};
const appInstance = {
lookup: (name: string) => (name === 'service:router' ? router : { location }),
} as unknown as ApplicationInstance;
const client = {
getOptions: () => ({ traceLifecycle: 'stream' }),
getDataCollectionOptions: () => ({ urlQueryParams: true }),
} as unknown as Client;
const pageloadSpan = new SentrySpan({ name: 'Pageload' });
const navigationSpan = new SentrySpan({ name: 'Navigation' });
const startPageloadSpan = vi.fn((_client: Client, options: StartSpanOptions) => {
pageloadSpan.updateName(options.name);
pageloadSpan.setAttributes(options.attributes ?? {});
return pageloadSpan;
});
const startNavigationSpan = vi.fn((_client: Client, options: StartSpanOptions) => {
navigationSpan.updateName(options.name);
navigationSpan.setAttributes(options.attributes ?? {});
return navigationSpan;
});

return {
client,
router,
pageloadSpan,
navigationSpan,
startPageloadSpan,
startNavigationSpan,
instrument: (config: Parameters<typeof instrumentEmberAppInstanceForPerformance>[2] = {}) =>
instrumentEmberAppInstanceForPerformance(client, appInstance, config, startPageloadSpan, startNavigationSpan),
routeWillChange: (transition: { from?: { name: string }; to?: { name?: string; localName?: string } }) => {
const handler = handlers.get('routeWillChange');
if (!handler) {
throw new Error('routeWillChange was not registered');
}
handler(transition as Transition);
},
};
}

describe('instrumentEmberAppInstanceForPerformance', () => {
let previousTransactionName: string | undefined;

beforeEach(() => {
previousTransactionName = getCurrentScope().getScopeData().transactionName;
getCurrentScope().setTransactionName(undefined);
vi.stubGlobal('location', { origin: 'https://ember.example.com', pathname: '/' });
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
getCurrentScope().setTransactionName(previousTransactionName);
});

it('adds the recognized route ID to the pageload', () => {
const fixture = createRouterFixture();

fixture.instrument();

expect(fixture.startPageloadSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, {
name: 'route:index',
attributes: {
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
'sentry.origin': 'auto.pageload.ember',
'router.navigation.route.id': 'index',
'url.path': '/',
'url.full': 'https://ember.example.com/',
'url.template': '/',
toRoute: 'index',
},
});
});

it('omits the pageload route ID when the recognized name is empty', () => {
const fixture = createRouterFixture();
fixture.router.recognize.mockReturnValue({ name: '', params: {} });

fixture.instrument();

expect(fixture.startPageloadSpan).toHaveBeenCalledTimes(1);
expect(spanToJSON(fixture.pageloadSpan).attributes).not.toHaveProperty('router.navigation.route.id');
});

it.each([true, false])('updates the initial pageload when navigation instrumentation is %s', instrumentNavigation => {
const fixture = createRouterFixture();
fixture.router.recognize.mockReturnValue(undefined);
fixture.instrument({ instrumentNavigation });
expect(spanToJSON(fixture.pageloadSpan).name).toBe('Pageload');
expect(spanToJSON(fixture.pageloadSpan).attributes).not.toHaveProperty('router.navigation.route.id');

fixture.routeWillChange({ to: { name: 'index' } });

expect(fixture.startPageloadSpan).toHaveBeenCalledTimes(1);
expect(fixture.startNavigationSpan).not.toHaveBeenCalled();
expect(spanToJSON(fixture.pageloadSpan).name).toBe('route:index');
expect(spanToJSON(fixture.pageloadSpan).attributes['router.navigation.route.id']).toBe('index');
expect(spanToJSON(fixture.pageloadSpan).attributes[SENTRY_SEGMENT_NAME_SOURCE]).toBe('route');
});

it('adds the destination route ID without a destination URL', () => {
const fixture = createRouterFixture();
fixture.instrument();

fixture.routeWillChange({ from: { name: 'index' }, to: { name: 'tracing' } });

expect(fixture.startNavigationSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, {
name: 'route:tracing',
attributes: {
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
'sentry.origin': 'auto.navigation.ember',
'router.navigation.route.id': 'tracing',
fromRoute: 'index',
toRoute: 'tracing',
},
});
});

it('uses the current route fallback when the transition has no destination', () => {
const fixture = createRouterFixture();
fixture.router.currentRouteName = 'tracing';
fixture.instrument();

fixture.routeWillChange({ from: { name: 'index' } });

expect(spanToJSON(fixture.navigationSpan).attributes['router.navigation.route.id']).toBe('tracing');
});

it.each([undefined, ''])('omits the navigation route ID when the available name is %j', currentRouteName => {
const fixture = createRouterFixture();
fixture.router.currentRouteName = currentRouteName;
fixture.instrument();

fixture.routeWillChange({ from: { name: 'index' } });

expect(fixture.startNavigationSpan).toHaveBeenCalledExactlyOnceWith(fixture.client, {
name: `route:${currentRouteName}`,
attributes: {
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
'sentry.origin': 'auto.navigation.ember',
fromRoute: 'index',
toRoute: currentRouteName,
},
});
});

it.each([undefined, ''])('preserves a caller route ID when the delayed pageload name is %j', currentRouteName => {
const fixture = createRouterFixture();
fixture.router.currentRouteName = currentRouteName;
fixture.router.recognize.mockReturnValue(undefined);
fixture.instrument();
fixture.pageloadSpan.setAttribute('router.navigation.route.id', 'caller-route');
const updateName = vi.spyOn(fixture.pageloadSpan, 'updateName');
const setAttributes = vi.spyOn(fixture.pageloadSpan, 'setAttributes');

fixture.routeWillChange({});

expect(updateName).toHaveBeenCalledExactlyOnceWith(`route:${currentRouteName}`);
expect(setAttributes).toHaveBeenCalledExactlyOnceWith({
[SENTRY_SEGMENT_NAME_SOURCE]: 'route',
'url.path': '/',
'url.full': 'https://ember.example.com/',
'url.template': '/',
toRoute: currentRouteName,
});
expect(spanToJSON(fixture.pageloadSpan).attributes['router.navigation.route.id']).toBe('caller-route');
});

it('does not create a pageload when pageload instrumentation is disabled', () => {
const fixture = createRouterFixture();

fixture.instrument({ instrumentPageLoad: false });
fixture.routeWillChange({ to: { name: 'index' } });

expect(fixture.startPageloadSpan).not.toHaveBeenCalled();
expect(fixture.startNavigationSpan).not.toHaveBeenCalled();
});

it('does not create a navigation when navigation instrumentation is disabled', () => {
const fixture = createRouterFixture();
fixture.instrument({ instrumentNavigation: false });

fixture.routeWillChange({ from: { name: 'index' }, to: { name: 'tracing' } });

expect(fixture.startNavigationSpan).not.toHaveBeenCalled();
});

it.each(['loading', 'error'])('does not create a navigation for an intermediate %s route', localName => {
const fixture = createRouterFixture();
fixture.instrument();
const endPageload = vi.spyOn(fixture.pageloadSpan, 'end');

fixture.routeWillChange({ from: { name: 'index' }, to: { name: `tracing.${localName}`, localName } });

expect(endPageload).not.toHaveBeenCalled();
expect(fixture.startNavigationSpan).not.toHaveBeenCalled();
});
});