Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tracing): add long animation frame tracing #12646

Merged
merged 15 commits into from
Jul 10, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
(() => {
const startTime = Date.now();

function getElasped() {
const time = Date.now();
return time - startTime;
}

while (getElasped() < 101) {
//
}
})();
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [
Sentry.browserTracingIntegration({ enableLongTask: false, enableLongAnimationFrame: false, idleTimeout: 9000 }),
],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div>Rendered Before Long Animation Frame</div>
<script src="https://example.com/path/to/script.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { Route } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Event } from '@sentry/types';

import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';

sentryTest(
'should not capture long animation frame when flag is disabled.',
async ({ browserName, getLocalTestPath, page }) => {
// Long animation frames only work on chrome
if (shouldSkipTracingTest() || browserName !== 'chromium') {
sentryTest.skip();
}

await page.route('**/path/to/script.js', (route: Route) =>
route.fulfill({ path: `${__dirname}/assets/script.js` }),
);

const url = await getLocalTestPath({ testDir: __dirname });

const eventData = await getFirstSentryEnvelopeRequest<Event>(page, url);
const uiSpans = eventData.spans?.filter(({ op }) => op?.startsWith('ui'));

expect(uiSpans?.length).toBe(0);
},
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
function getElapsed(startTime) {
const time = Date.now();
return time - startTime;
}

function handleClick() {
const startTime = Date.now();
while (getElapsed(startTime) < 105) {
//
}
}

function start() {
const startTime = Date.now();
while (getElapsed(startTime) < 105) {
//
}
}

// trigger 2 long-animation-frame events
// one from the top-level and the other from an event-listener
start();

const button = document.getElementById('clickme');
button.addEventListener('click', handleClick);
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as Sentry from '@sentry/browser';

window.Sentry = Sentry;

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
integrations: [
Sentry.browserTracingIntegration({
idleTimeout: 9000,
enableLongTask: false,
enableLongAnimationFrame: true,
}),
],
tracesSampleRate: 1,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<div>Rendered Before Long Animation Frame</div>
<button id="clickme">
click me to start the long animation!
</button>
<script src="https://example.com/path/to/script.js"></script>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { Route } from '@playwright/test';
import { expect } from '@playwright/test';
import type { Event } from '@sentry/types';

import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '@sentry/browser';
import { sentryTest } from '../../../../utils/fixtures';
import { getFirstSentryEnvelopeRequest, shouldSkipTracingTest } from '../../../../utils/helpers';

sentryTest(
'should capture long animation frame for top-level script.',
async ({ browserName, getLocalTestPath, page }) => {
// Long animation frames only work on chrome
if (shouldSkipTracingTest() || browserName !== 'chromium') {
sentryTest.skip();
}

await page.route('**/path/to/script.js', (route: Route) =>
route.fulfill({ path: `${__dirname}/assets/script.js` }),
);

const url = await getLocalTestPath({ testDir: __dirname });

const promise = getFirstSentryEnvelopeRequest<Event>(page);

await page.goto(url);

await new Promise(resolve => setTimeout(resolve, 200));

const eventData = await promise;

const uiSpans = eventData.spans?.filter(({ op }) => op?.startsWith('ui.long-animation-frame'));

expect(uiSpans?.length).toEqual(1);

const [topLevelUISpan] = uiSpans || [];
expect(topLevelUISpan).toEqual(
expect.objectContaining({
op: 'ui.long-animation-frame',
description: 'Main UI thread blocked',
parent_span_id: eventData.contexts?.trace?.span_id,
data: {
'code.filepath': 'https://example.com/path/to/script.js',
'browser.script.source_char_position': 0,
'browser.script.invoker': 'https://example.com/path/to/script.js',
'browser.script.invoker_type': 'classic-script',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.long-animation-frame',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics',
},
}),
);
const start = topLevelUISpan.start_timestamp ?? 0;
const end = topLevelUISpan.timestamp ?? 0;
const duration = end - start;

expect(duration).toBeGreaterThanOrEqual(0.1);
expect(duration).toBeLessThanOrEqual(0.15);
},
);

sentryTest(
'should capture long animation frame for event listener.',
async ({ browserName, getLocalTestPath, page }) => {
// Long animation frames only work on chrome
if (shouldSkipTracingTest() || browserName !== 'chromium') {
sentryTest.skip();
}

await page.route('**/path/to/script.js', (route: Route) =>
route.fulfill({ path: `${__dirname}/assets/script.js` }),
);

const url = await getLocalTestPath({ testDir: __dirname });

const promise = getFirstSentryEnvelopeRequest<Event>(page);

await page.goto(url);

// trigger long animation frame function
await page.getByRole('button').click();

await new Promise(resolve => setTimeout(resolve, 200));

const eventData = await promise;

const uiSpans = eventData.spans?.filter(({ op }) => op?.startsWith('ui.long-animation-frame'));

expect(uiSpans?.length).toEqual(2);

// ignore the first ui span (top-level long animation frame)
const [, eventListenerUISpan] = uiSpans || [];

expect(eventListenerUISpan).toEqual(
expect.objectContaining({
op: 'ui.long-animation-frame',
description: 'Main UI thread blocked',
parent_span_id: eventData.contexts?.trace?.span_id,
data: {
'browser.script.invoker': 'BUTTON#clickme.onclick',
'browser.script.invoker_type': 'event-listener',
[SEMANTIC_ATTRIBUTE_SENTRY_OP]: 'ui.long-animation-frame',
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics',
},
}),
);
const start = eventListenerUISpan.start_timestamp ?? 0;
const end = eventListenerUISpan.timestamp ?? 0;
const duration = end - start;

expect(duration).toBeGreaterThanOrEqual(0.1);
expect(duration).toBeLessThanOrEqual(0.15);
},
);
1 change: 1 addition & 0 deletions packages/browser-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
addPerformanceEntries,
startTrackingInteractions,
startTrackingLongTasks,
startTrackingLongAnimationFrames,
startTrackingWebVitals,
startTrackingINP,
registerInpInteractionListener,
Expand Down
54 changes: 54 additions & 0 deletions packages/browser-utils/src/metrics/browserMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { spanToJSON } from '@sentry/core';
import { DEBUG_BUILD } from '../debug-build';
import { WINDOW } from '../types';
import {
type PerformanceLongAnimationFrameTiming,
addClsInstrumentationHandler,
addFidInstrumentationHandler,
addLcpInstrumentationHandler,
Expand Down Expand Up @@ -120,6 +121,59 @@ export function startTrackingLongTasks(): void {
});
}

/**
* Start tracking long animation frames.
*/
export function startTrackingLongAnimationFrames(): void {
// NOTE: the current web-vitals version (3.5.2) does not support long-animation-frame, so
// we directly observe `long-animation-frame` events instead of through the web-vitals
// `observe` helper function.
const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries() as PerformanceLongAnimationFrameTiming[]) {
if (!getActiveSpan()) {
return;
}
if (!entry.scripts[0]) {
return;
}

const startTime = msToSec((browserPerformanceTimeOrigin as number) + entry.startTime);
const duration = msToSec(entry.duration);

const attributes: SpanAttributes = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ui.browser.metrics',
};
const initialScript = entry.scripts[0];
if (initialScript) {
const { invoker, invokerType, sourceURL, sourceFunctionName, sourceCharPosition } = initialScript;
attributes['browser.script.invoker'] = invoker;
attributes['browser.script.invoker_type'] = invokerType;
if (sourceURL) {
attributes['code.filepath'] = sourceURL;
}
if (sourceFunctionName) {
attributes['code.function'] = sourceFunctionName;
}
if (sourceCharPosition !== -1) {
attributes['browser.script.source_char_position'] = sourceCharPosition;
}
}

const span = startInactiveSpan({
name: 'Main UI thread blocked',
op: 'ui.long-animation-frame',
startTime,
attributes,
});
if (span) {
span.end(startTime + duration);
}
}
});

observer.observe({ type: 'long-animation-frame', buffered: true });
}

/**
* Start tracking interaction events.
*/
Expand Down
11 changes: 11 additions & 0 deletions packages/browser-utils/src/metrics/instrument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ interface PerformanceEventTiming extends PerformanceEntry {
interactionId?: number;
}

interface PerformanceScriptTiming extends PerformanceEntry {
sourceURL: string;
sourceFunctionName: string;
sourceCharPosition: number;
invoker: string;
invokerType: string;
}
export interface PerformanceLongAnimationFrameTiming extends PerformanceEntry {
scripts: PerformanceScriptTiming[];
}

interface Metric {
/**
* The name of the metric (in acronym form).
Expand Down
15 changes: 14 additions & 1 deletion packages/browser/src/tracing/browserTracingIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
registerInpInteractionListener,
startTrackingINP,
startTrackingInteractions,
startTrackingLongAnimationFrames,
startTrackingLongTasks,
startTrackingWebVitals,
} from '@sentry-internal/browser-utils';
Expand Down Expand Up @@ -102,6 +103,13 @@ export interface BrowserTracingOptions {
*/
enableLongTask: boolean;

/**
* If true, Sentry will capture long animation frames and add them to the corresponding transaction.
*
* Default: false
*/
enableLongAnimationFrame: boolean;

/**
* If true, Sentry will capture first input delay and add it to the corresponding transaction.
*
Expand Down Expand Up @@ -160,6 +168,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = {
instrumentPageLoad: true,
markBackgroundSpan: true,
enableLongTask: true,
enableLongAnimationFrame: false,
enableInp: true,
_experiments: {},
...defaultRequestInstrumentationOptions,
Expand All @@ -180,6 +189,7 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
const {
enableInp,
enableLongTask,
enableLongAnimationFrame,
_experiments: { enableInteractions },
beforeStartSpan,
idleTimeout,
Expand All @@ -203,9 +213,12 @@ export const browserTracingIntegration = ((_options: Partial<BrowserTracingOptio
startTrackingINP();
}

if (enableLongTask) {
if (enableLongAnimationFrame && PerformanceObserver.supportedEntryTypes.includes('long-animation-frame')) {
startTrackingLongAnimationFrames();
KevinL10 marked this conversation as resolved.
Show resolved Hide resolved
KevinL10 marked this conversation as resolved.
Show resolved Hide resolved
} else if (enableLongTask) {
startTrackingLongTasks();
}

if (enableInteractions) {
startTrackingInteractions();
}
Expand Down
Loading