Skip to content

Commit f32e970

Browse files
committed
performance
1 parent 9786d50 commit f32e970

File tree

6 files changed

+285
-9
lines changed

6 files changed

+285
-9
lines changed

packages/tracing/src/idletransaction.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export class IdleTransactionSpanRecorder extends SpanRecorder {
3434
};
3535

3636
super.add(span);
37-
if (this._pushActivity) {
37+
if (this._pushActivity && !span.endTimestamp) {
3838
this._pushActivity(span.spanId);
3939
}
4040
}

packages/tracing/src/integrations/browsertracing.ts

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { SpanStatus } from '../spanstatus';
88

99
import { registerBackgroundTabDetection } from './tracing/backgroundtab';
1010
import { registerErrorHandlers } from './tracing/errors';
11+
import { Metrics } from './tracing/performance';
1112
import {
1213
defaultRequestInstrumentionOptions,
1314
RequestInstrumentationClass,
@@ -21,11 +22,6 @@ import {
2122
RoutingInstrumentationOptions,
2223
} from './tracing/router';
2324

24-
/**
25-
* TODO: Tracing._addPerformanceEntries
26-
* - This is a beforeFinish() hook here
27-
*/
28-
2925
/**
3026
* Options for Browser Tracing integration
3127
*/
@@ -91,12 +87,16 @@ export class BrowserTracing implements Integration {
9187
*/
9288
public static options: BrowserTracingOptions;
9389

90+
private readonly _emitOptionsWarning: boolean = false;
91+
9492
/**
9593
* Returns current hub.
9694
*/
9795
private static _getCurrentHub?: () => Hub;
9896

9997
public constructor(_options?: Partial<BrowserTracingOptions>) {
98+
Metrics.init();
99+
100100
const defaults = {
101101
debug: {
102102
spanDebugTimingInfo: false,
@@ -107,6 +107,12 @@ export class BrowserTracing implements Integration {
107107
requestTracing: RequestTracing,
108108
routerTracing: RouterTracing,
109109
};
110+
111+
// NOTE: Logger doesn't work in contructors, as it's initialized after integrations instances
112+
if (!_options || !Array.isArray(_options.tracingOrigins) || _options.tracingOrigins.length === 0) {
113+
this._emitOptionsWarning = true;
114+
}
115+
110116
BrowserTracing.options = {
111117
...defaultRoutingInstrumentationOptions,
112118
...defaultRequestInstrumentionOptions,
@@ -121,11 +127,24 @@ export class BrowserTracing implements Integration {
121127
public setupOnce(addGlobalEventProcessor: (callback: EventProcessor) => void, getCurrentHub: () => Hub): void {
122128
BrowserTracing._getCurrentHub = getCurrentHub;
123129

130+
if (this._emitOptionsWarning) {
131+
logger.warn(
132+
'[Tracing] You need to define `tracingOrigins` in the options. Set an array of urls or patterns to trace.',
133+
);
134+
logger.warn(
135+
`[Tracing] We added a reasonable default for you: ${defaultRequestInstrumentionOptions.tracingOrigins}`,
136+
);
137+
}
138+
124139
const hub = getCurrentHub();
125140

141+
// Track pageload/navigation transactions
126142
BrowserTracing._initRoutingInstrumentation(hub);
143+
// Track XHR and Fetch requests
127144
BrowserTracing._initRequestInstrumentation();
145+
// Set status of transactions on error
128146
registerErrorHandlers();
147+
// Finish transactions if document is no longer visible
129148
if (BrowserTracing.options.markBackgroundTransactions) {
130149
registerBackgroundTabDetection();
131150
}
@@ -183,10 +202,12 @@ export class BrowserTracing implements Integration {
183202
startTransactionOnPageLoad,
184203
});
185204

186-
// tslint:disable-next-line: no-empty
187-
const beforeFinish = (_: IdleTransaction): void => {};
188205
const transactionContext = BrowserTracing._getTransactionContext();
189206

207+
const beforeFinish = (transaction: IdleTransaction) => {
208+
Metrics.addPerformanceEntries(transaction);
209+
};
210+
190211
routerTracing.init(hub, beforeFinish, transactionContext);
191212
}
192213

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1 @@
11
export { Express } from './express';
2-
export { Tracing } from './tracing';
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { getGlobalObject } from '@sentry/utils';
2+
3+
import { Span } from '../../span';
4+
import { Transaction } from '../../transaction';
5+
import { BrowserTracing } from '../browsertracing';
6+
7+
import { msToSec } from './utils';
8+
9+
const global = getGlobalObject<Window>();
10+
11+
/**
12+
* Adds metrics to transactions.
13+
*/
14+
// tslint:disable-next-line: no-unnecessary-class
15+
export class Metrics {
16+
private static _lcp: Record<string, any>;
17+
18+
private static readonly _performanceCursor: number = 0;
19+
20+
private static _forceLCP = () => {
21+
/* No-op, replaced later if LCP API is available. */
22+
return;
23+
};
24+
25+
/**
26+
* Starts tracking the Largest Contentful Paint on the current page.
27+
*/
28+
private static _trackLCP(): void {
29+
// Based on reference implementation from https://web.dev/lcp/#measure-lcp-in-javascript.
30+
31+
// Use a try/catch instead of feature detecting `largest-contentful-paint`
32+
// support, since some browsers throw when using the new `type` option.
33+
// https://bugs.webkit.org/show_bug.cgi?id=209216
34+
try {
35+
// Keep track of whether (and when) the page was first hidden, see:
36+
// https://github.com/w3c/page-visibility/issues/29
37+
// NOTE: ideally this check would be performed in the document <head>
38+
// to avoid cases where the visibility state changes before this code runs.
39+
let firstHiddenTime = document.visibilityState === 'hidden' ? 0 : Infinity;
40+
document.addEventListener(
41+
'visibilitychange',
42+
event => {
43+
firstHiddenTime = Math.min(firstHiddenTime, event.timeStamp);
44+
},
45+
{ once: true },
46+
);
47+
48+
const updateLCP = (entry: PerformanceEntry) => {
49+
// Only include an LCP entry if the page wasn't hidden prior to
50+
// the entry being dispatched. This typically happens when a page is
51+
// loaded in a background tab.
52+
if (entry.startTime < firstHiddenTime) {
53+
// NOTE: the `startTime` value is a getter that returns the entry's
54+
// `renderTime` value, if available, or its `loadTime` value otherwise.
55+
// The `renderTime` value may not be available if the element is an image
56+
// that's loaded cross-origin without the `Timing-Allow-Origin` header.
57+
Metrics._lcp = {
58+
// @ts-ignore
59+
...(entry.id && { elementId: entry.id }),
60+
// @ts-ignore
61+
...(entry.size && { elementSize: entry.size }),
62+
value: entry.startTime,
63+
};
64+
}
65+
};
66+
67+
// Create a PerformanceObserver that calls `updateLCP` for each entry.
68+
const po = new PerformanceObserver(entryList => {
69+
entryList.getEntries().forEach(updateLCP);
70+
});
71+
72+
// Observe entries of type `largest-contentful-paint`, including buffered entries,
73+
// i.e. entries that occurred before calling `observe()` below.
74+
po.observe({
75+
buffered: true,
76+
// @ts-ignore
77+
type: 'largest-contentful-paint',
78+
});
79+
80+
Metrics._forceLCP = () => {
81+
po.takeRecords().forEach(updateLCP);
82+
};
83+
} catch (e) {
84+
// Do nothing if the browser doesn't support this API.
85+
}
86+
}
87+
88+
/**
89+
* Start tracking metrics
90+
*/
91+
public static init(): void {
92+
if (global.performance) {
93+
if (global.performance.mark) {
94+
global.performance.mark('sentry-tracing-init');
95+
}
96+
Metrics._trackLCP();
97+
}
98+
}
99+
100+
/**
101+
* Adds performance related spans to a transaction
102+
*/
103+
public static addPerformanceEntries(transaction: Transaction): void {
104+
if (!global.performance || !global.performance.getEntries) {
105+
// Gatekeeper if performance API not available
106+
return;
107+
}
108+
109+
BrowserTracing.log('[Tracing] Adding & adjusting spans using Performance API');
110+
// FIXME: depending on the 'op' directly is brittle.
111+
if (transaction.op === 'pageload') {
112+
// Force any pending records to be dispatched.
113+
Metrics._forceLCP();
114+
if (Metrics._lcp) {
115+
// Set the last observed LCP score.
116+
transaction.setData('_sentry_web_vitals', { LCP: Metrics._lcp });
117+
}
118+
}
119+
120+
const timeOrigin = msToSec(performance.timeOrigin);
121+
122+
// tslint:disable-next-line: completed-docs
123+
function addPerformanceNavigationTiming(parent: Span, entry: Record<string, number>, event: string): void {
124+
parent.startChild({
125+
description: event,
126+
endTimestamp: timeOrigin + msToSec(entry[`${event}End`]),
127+
op: 'browser',
128+
startTimestamp: timeOrigin + msToSec(entry[`${event}Start`]),
129+
});
130+
}
131+
132+
// tslint:disable-next-line: completed-docs
133+
function addRequest(parent: Span, entry: Record<string, number>): void {
134+
parent.startChild({
135+
description: 'request',
136+
endTimestamp: timeOrigin + msToSec(entry.responseEnd),
137+
op: 'browser',
138+
startTimestamp: timeOrigin + msToSec(entry.requestStart),
139+
});
140+
141+
parent.startChild({
142+
description: 'response',
143+
endTimestamp: timeOrigin + msToSec(entry.responseEnd),
144+
op: 'browser',
145+
startTimestamp: timeOrigin + msToSec(entry.responseStart),
146+
});
147+
}
148+
149+
let entryScriptSrc: string | undefined;
150+
151+
if (global.document) {
152+
// tslint:disable-next-line: prefer-for-of
153+
for (let i = 0; i < document.scripts.length; i++) {
154+
// We go through all scripts on the page and look for 'data-entry'
155+
// We remember the name and measure the time between this script finished loading and
156+
// our mark 'sentry-tracing-init'
157+
if (document.scripts[i].dataset.entry === 'true') {
158+
entryScriptSrc = document.scripts[i].src;
159+
break;
160+
}
161+
}
162+
}
163+
164+
let entryScriptStartEndTime: number | undefined;
165+
let tracingInitMarkStartTime: number | undefined;
166+
167+
// tslint:disable: no-unsafe-any
168+
performance
169+
.getEntries()
170+
.slice(Metrics._performanceCursor)
171+
.forEach((entry: any) => {
172+
const startTime = msToSec(entry.startTime as number);
173+
const duration = msToSec(entry.duration as number);
174+
175+
if (transaction.op === 'navigation' && timeOrigin + startTime < transaction.startTimestamp) {
176+
return;
177+
}
178+
179+
switch (entry.entryType) {
180+
case 'navigation':
181+
addPerformanceNavigationTiming(transaction, entry, 'unloadEvent');
182+
addPerformanceNavigationTiming(transaction, entry, 'domContentLoadedEvent');
183+
addPerformanceNavigationTiming(transaction, entry, 'loadEvent');
184+
addPerformanceNavigationTiming(transaction, entry, 'connect');
185+
addPerformanceNavigationTiming(transaction, entry, 'domainLookup');
186+
addRequest(transaction, entry);
187+
break;
188+
case 'mark':
189+
case 'paint':
190+
case 'measure':
191+
const measureStartTimestamp = timeOrigin + startTime;
192+
const measureEndTimestamp = measureStartTimestamp + duration;
193+
194+
if (tracingInitMarkStartTime === undefined && entry.name === 'sentry-tracing-init') {
195+
tracingInitMarkStartTime = measureStartTimestamp;
196+
}
197+
198+
transaction.startChild({
199+
description: entry.name,
200+
endTimestamp: measureEndTimestamp,
201+
op: entry.entryType,
202+
startTimestamp: measureStartTimestamp,
203+
});
204+
break;
205+
case 'resource':
206+
const resourceName = entry.name.replace(window.location.origin, '');
207+
if (entry.initiatorType === 'xmlhttprequest' || entry.initiatorType === 'fetch') {
208+
// We need to update existing spans with new timing info
209+
if (transaction.spanRecorder) {
210+
transaction.spanRecorder.spans.map((finishedSpan: Span) => {
211+
if (finishedSpan.description && finishedSpan.description.indexOf(resourceName) !== -1) {
212+
finishedSpan.startTimestamp = timeOrigin + startTime;
213+
finishedSpan.endTimestamp = finishedSpan.startTimestamp + duration;
214+
}
215+
});
216+
}
217+
} else {
218+
const startTimestamp = timeOrigin + startTime;
219+
const endTimestamp = startTimestamp + duration;
220+
221+
// We remember the entry script end time to calculate the difference to the first init mark
222+
if (entryScriptStartEndTime === undefined && (entryScriptSrc || '').indexOf(resourceName) > -1) {
223+
entryScriptStartEndTime = endTimestamp;
224+
}
225+
226+
transaction.startChild({
227+
description: `${entry.initiatorType} ${resourceName}`,
228+
endTimestamp,
229+
op: `resource`,
230+
startTimestamp,
231+
});
232+
}
233+
break;
234+
default:
235+
// Ignore other entry types.
236+
}
237+
});
238+
}
239+
}

packages/tracing/src/integrations/tracing/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,11 @@ export function getActiveTransaction(): Transaction | IdleTransaction | undefine
1515

1616
return undefined;
1717
}
18+
19+
/**
20+
* Converts from milliseconds to seconds
21+
* @param time time in ms
22+
*/
23+
export function msToSec(time: number): number {
24+
return time / 1000;
25+
}

packages/tracing/test/idletransaction.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ describe('IdleTransaction', () => {
5959
expect(mockFinish).toHaveBeenCalledTimes(1);
6060
});
6161

62+
it('does not push activities if a span already has an end timestamp', () => {
63+
const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000);
64+
transaction.initSpanRecorder(10);
65+
expect(transaction.activities).toMatchObject({});
66+
67+
transaction.startChild({ startTimestamp: 1234, endTimestamp: 5678 });
68+
expect(transaction.activities).toMatchObject({});
69+
});
70+
6271
it('does not finish if there are still active activities', () => {
6372
const mockFinish = jest.fn();
6473
const transaction = new IdleTransaction({ name: 'foo' }, hub, 1000);

0 commit comments

Comments
 (0)