diff --git a/.size-limit.js b/.size-limit.js index 1ce4b252963d..9ead34df50f0 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -52,7 +52,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '86 KB', + limit: '87 KB', }, { name: '@sentry/browser (incl. Feedback)', @@ -165,7 +165,7 @@ module.exports = [ path: createCDNPath('bundle.tracing.replay.feedback.min.js'), gzip: false, brotli: false, - limit: '261 KB', + limit: '264 KB', }, // Next.js SDK (ESM) { diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/assets/script.js b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/subject.js similarity index 77% rename from dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/assets/script.js rename to dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/subject.js index fecb5ca8a758..f9503ef6f261 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/assets/script.js +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/subject.js @@ -1,4 +1,4 @@ -const delay = e => { +const blockUI = e => { const startTime = Date.now(); function getElasped() { @@ -13,6 +13,6 @@ const delay = e => { e.target.classList.add('clicked'); }; -document.querySelector('[data-test-id=interaction-button]').addEventListener('click', delay); -document.querySelector('[data-test-id=annotated-button]').addEventListener('click', delay); -document.querySelector('[data-test-id=styled-button]').addEventListener('click', delay); +document.querySelector('[data-test-id=interaction-button]').addEventListener('click', blockUI); +document.querySelector('[data-test-id=annotated-button]').addEventListener('click', blockUI); +document.querySelector('[data-test-id=styled-button]').addEventListener('click', blockUI); diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/template.html b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/template.html index d3bda54d7443..0b32a75fc3b0 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/template.html +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/template.html @@ -8,6 +8,5 @@ - diff --git a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/test.ts b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/test.ts index 5b032ace7f8c..bb219eda38c7 100644 --- a/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/test.ts +++ b/dev-packages/browser-integration-tests/suites/tracing/browserTracingIntegration/interactions/test.ts @@ -1,6 +1,5 @@ -import type { Route } from '@playwright/test'; import { expect } from '@playwright/test'; -import type { Contexts, Event, SpanJSON } from '@sentry/types'; +import type { Event as SentryEvent } from '@sentry/types'; import { sentryTest } from '../../../../utils/fixtures'; import { @@ -9,33 +8,24 @@ import { shouldSkipTracingTest, } from '../../../../utils/helpers'; -type TransactionJSON = SpanJSON & { - spans: SpanJSON[]; - contexts: Contexts; - platform: string; - type: string; -}; - -const wait = (time: number) => new Promise(res => setTimeout(res, time)); - sentryTest('should capture interaction transaction. @firefox', async ({ browserName, getLocalTestPath, page }) => { const supportedBrowsers = ['chromium', 'firefox']; if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { sentryTest.skip(); } - - await page.route('**/path/to/script.js', (route: Route) => route.fulfill({ path: `${__dirname}/assets/script.js` })); - const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await getFirstSentryEnvelopeRequest(page); + await getFirstSentryEnvelopeRequest(page); + + const envelopesPromise = getMultipleSentryEnvelopeRequests(page, 1); await page.locator('[data-test-id=interaction-button]').click(); await page.locator('.clicked[data-test-id=interaction-button]').isVisible(); - const envelopes = await getMultipleSentryEnvelopeRequests(page, 1); + const envelopes = await envelopesPromise; + expect(envelopes).toHaveLength(1); const eventData = envelopes[0]; @@ -64,18 +54,15 @@ sentryTest( sentryTest.skip(); } - await page.route('**/path/to/script.js', (route: Route) => - route.fulfill({ path: `${__dirname}/assets/script.js` }), - ); - const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await getFirstSentryEnvelopeRequest(page); + await getFirstSentryEnvelopeRequest(page); for (let i = 0; i < 4; i++) { - await wait(100); + const envelopePromise = getMultipleSentryEnvelopeRequests(page, 1); + await page.waitForTimeout(1000); await page.locator('[data-test-id=interaction-button]').click(); - const envelope = await getMultipleSentryEnvelopeRequests(page, 1); + const envelope = await envelopePromise; expect(envelope[0].spans).toHaveLength(1); } }, @@ -90,18 +77,16 @@ sentryTest( sentryTest.skip(); } - await page.route('**/path/to/script.js', (route: Route) => - route.fulfill({ path: `${__dirname}/assets/script.js` }), - ); - const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await getFirstSentryEnvelopeRequest(page); + await getFirstSentryEnvelopeRequest(page); + + const envelopePromise = getMultipleSentryEnvelopeRequests(page, 1); await page.locator('[data-test-id=annotated-button]').click(); - const envelopes = await getMultipleSentryEnvelopeRequests(page, 1); + const envelopes = await envelopePromise; expect(envelopes).toHaveLength(1); const eventData = envelopes[0]; @@ -122,21 +107,19 @@ sentryTest( sentryTest.skip(); } - await page.route('**/path/to/script.js', (route: Route) => - route.fulfill({ path: `${__dirname}/assets/script.js` }), - ); - const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await getFirstSentryEnvelopeRequest(page); + await getFirstSentryEnvelopeRequest(page); + + const envelopesPromise = getMultipleSentryEnvelopeRequests(page, 1); await page.locator('[data-test-id=styled-button]').click(); - const envelopes = await getMultipleSentryEnvelopeRequests(page, 1); + const envelopes = await envelopesPromise; expect(envelopes).toHaveLength(1); - const eventData = envelopes[0]; + const eventData = envelopes[0]; expect(eventData.spans).toHaveLength(1); const interactionSpan = eventData.spans![0]; diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js new file mode 100644 index 000000000000..b558562e4cd4 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/init.js @@ -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: 1000, + enableLongTask: false, + enableInp: true, + }), + ], + tracesSampleRate: 1, +}); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/subject.js b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/subject.js new file mode 100644 index 000000000000..ed6db5b5afe2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/subject.js @@ -0,0 +1,18 @@ +const blockUI = (delay = 70) => e => { + const startTime = Date.now(); + + function getElasped() { + const time = Date.now(); + return time - startTime; + } + + while (getElasped() < delay) { + // + } + + e.target.classList.add('clicked'); +}; + +document.querySelector('[data-test-id=not-so-slow-button]').addEventListener('click', blockUI(300)); +document.querySelector('[data-test-id=slow-button]').addEventListener('click', blockUI(450)); +document.querySelector('[data-test-id=normal-button]').addEventListener('click', blockUI()); diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/template.html b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/template.html new file mode 100644 index 000000000000..25c6920f07e2 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/template.html @@ -0,0 +1,12 @@ + + + + + + +
Rendered Before Long Task
+ + + + + diff --git a/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts new file mode 100644 index 000000000000..799a6df407a3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/tracing/metrics/web-vitals-inp/test.ts @@ -0,0 +1,107 @@ +import { expect } from '@playwright/test'; +import type { Event as SentryEvent, SpanJSON } from '@sentry/types'; + +import { sentryTest } from '../../../../utils/fixtures'; +import { + getFirstSentryEnvelopeRequest, + getMultipleSentryEnvelopeRequests, + shouldSkipTracingTest, +} from '../../../../utils/helpers'; + +sentryTest('should capture an INP click event span.', async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await getFirstSentryEnvelopeRequest(page); // wait for page load + + const spanEnvelopesPromise = getMultipleSentryEnvelopeRequests(page, 1, { + envelopeType: 'span', + }); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + // Get the INP span envelope + const spanEnvelopes = await spanEnvelopesPromise; + + expect(spanEnvelopes).toHaveLength(1); + expect(spanEnvelopes[0].op).toBe('ui.interaction.click'); + expect(spanEnvelopes[0].description).toBe('body > NormalButton'); + expect(spanEnvelopes[0].exclusive_time).toBeGreaterThan(0); + expect(spanEnvelopes[0].measurements?.inp.value).toBeGreaterThan(0); + expect(spanEnvelopes[0].measurements?.inp.unit).toBe('millisecond'); +}); + +sentryTest( + 'should choose the slowest interaction click event when INP is triggered.', + async ({ browserName, getLocalTestPath, page }) => { + const supportedBrowsers = ['chromium']; + + if (shouldSkipTracingTest() || !supportedBrowsers.includes(browserName)) { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestPath({ testDir: __dirname }); + + await page.goto(url); + await getFirstSentryEnvelopeRequest(page); + + await page.locator('[data-test-id=normal-button]').click(); + await page.locator('.clicked[data-test-id=normal-button]').isVisible(); + + await page.waitForTimeout(500); + + await page.locator('[data-test-id=slow-button]').click(); + await page.locator('.clicked[data-test-id=slow-button]').isVisible(); + + await page.waitForTimeout(500); + + const spanEnvelopesPromise = getMultipleSentryEnvelopeRequests(page, 1, { + envelopeType: 'span', + }); + + // Page hide to trigger INP + await page.evaluate(() => { + window.dispatchEvent(new Event('pagehide')); + }); + + // Get the INP span envelope + const spanEnvelopes = await spanEnvelopesPromise; + + expect(spanEnvelopes).toHaveLength(1); + expect(spanEnvelopes[0].op).toBe('ui.interaction.click'); + expect(spanEnvelopes[0].description).toBe('body > SlowButton'); + expect(spanEnvelopes[0].exclusive_time).toBeGreaterThan(400); + expect(spanEnvelopes[0].measurements?.inp.value).toBeGreaterThan(400); + expect(spanEnvelopes[0].measurements?.inp.unit).toBe('millisecond'); + }, +); diff --git a/packages/browser-utils/src/index.ts b/packages/browser-utils/src/index.ts index f65c78a6d3bf..53e2f24068d6 100644 --- a/packages/browser-utils/src/index.ts +++ b/packages/browser-utils/src/index.ts @@ -11,6 +11,7 @@ export { startTrackingInteractions, startTrackingLongTasks, startTrackingWebVitals, + startTrackingINP, } from './metrics/browserMetrics'; export { addClickKeypressInstrumentationHandler } from './instrument/dom'; diff --git a/packages/browser-utils/src/metrics/browserMetrics.ts b/packages/browser-utils/src/metrics/browserMetrics.ts index 7c6620ff7911..deca3765e404 100644 --- a/packages/browser-utils/src/metrics/browserMetrics.ts +++ b/packages/browser-utils/src/metrics/browserMetrics.ts @@ -14,7 +14,7 @@ import { addTtfbInstrumentationHandler, } from './instrument'; import { WINDOW } from './types'; -import { isMeasurementValue, startAndEndSpan } from './utils'; +import { getBrowserPerformanceAPI, isMeasurementValue, msToSec, startAndEndSpan } from './utils'; import { getNavigationEntry } from './web-vitals/lib/getNavigationEntry'; import { getVisibilityWatcher } from './web-vitals/lib/getVisibilityWatcher'; @@ -58,19 +58,6 @@ interface NavigatorDeviceMemory { const MAX_INT_AS_BYTES = 2147483647; -/** - * Converts from milliseconds to seconds - * @param time time in ms - */ -function msToSec(time: number): number { - return time / 1000; -} - -function getBrowserPerformanceAPI(): Performance | undefined { - // @ts-expect-error we want to make sure all of these are available, even if TS is sure they are - return WINDOW && WINDOW.addEventListener && WINDOW.performance; -} - let _performanceCursor: number = 0; let _measurements: Measurements = {}; @@ -170,6 +157,8 @@ export function startTrackingInteractions(): void { }); } +export { startTrackingINP } from './inp'; + /** Starts tracking the Cumulative Layout Shift on the current page. */ function _trackCLS(): () => void { return addClsInstrumentationHandler(({ metric }) => { @@ -226,7 +215,7 @@ function _trackTtfb(): () => void { }); } -/** Add performance related spans to a span */ +/** Add performance related spans to a transaction */ export function addPerformanceEntries(span: Span): void { const performance = getBrowserPerformanceAPI(); if (!performance || !WINDOW.performance.getEntries || !browserPerformanceTimeOrigin) { diff --git a/packages/browser-utils/src/metrics/inp.ts b/packages/browser-utils/src/metrics/inp.ts new file mode 100644 index 000000000000..5c7808d264cf --- /dev/null +++ b/packages/browser-utils/src/metrics/inp.ts @@ -0,0 +1,152 @@ +import { + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + SentrySpan, + createSpanEnvelope, + getActiveSpan, + getClient, + getCurrentScope, + getRootSpan, + sampleSpan, + spanIsSampled, + spanToJSON, +} from '@sentry/core'; +import type { Integration, SpanAttributes } from '@sentry/types'; +import { browserPerformanceTimeOrigin, dropUndefinedKeys, htmlTreeAsString, logger } from '@sentry/utils'; +import { DEBUG_BUILD } from '../debug-build'; +import { addInpInstrumentationHandler } from './instrument'; +import { getBrowserPerformanceAPI, msToSec } from './utils'; + +/** + * Start tracking INP webvital events. + */ +export function startTrackingINP(): () => void { + const performance = getBrowserPerformanceAPI(); + if (performance && browserPerformanceTimeOrigin) { + const inpCallback = _trackINP(); + + return (): void => { + inpCallback(); + }; + } + + return () => undefined; +} + +const INP_ENTRY_MAP: Record = { + click: 'click', + pointerdown: 'click', + pointerup: 'click', + mousedown: 'click', + mouseup: 'click', + touchstart: 'click', + touchend: 'click', + mouseover: 'hover', + mouseout: 'hover', + mouseenter: 'hover', + mouseleave: 'hover', + pointerover: 'hover', + pointerout: 'hover', + pointerenter: 'hover', + pointerleave: 'hover', + dragstart: 'drag', + dragend: 'drag', + drag: 'drag', + dragenter: 'drag', + dragleave: 'drag', + dragover: 'drag', + drop: 'drag', + keydown: 'press', + keyup: 'press', + keypress: 'press', + input: 'press', +}; + +/** Starts tracking the Interaction to Next Paint on the current page. */ +function _trackINP(): () => void { + return addInpInstrumentationHandler(({ metric }) => { + const client = getClient(); + if (!client || metric.value == undefined) { + return; + } + + const entry = metric.entries.find(entry => entry.duration === metric.value && INP_ENTRY_MAP[entry.name]); + + if (!entry) { + return; + } + + const interactionType = INP_ENTRY_MAP[entry.name]; + + const options = client.getOptions(); + /** Build the INP span, create an envelope from the span, and then send the envelope */ + const startTime = msToSec((browserPerformanceTimeOrigin as number) + entry.startTime); + const duration = msToSec(metric.value); + const scope = getCurrentScope(); + const activeSpan = getActiveSpan(); + const rootSpan = activeSpan ? getRootSpan(activeSpan) : undefined; + + const routeName = rootSpan ? spanToJSON(rootSpan).description : undefined; + const user = scope.getUser(); + + // We need to get the replay, user, and activeTransaction from the current scope + // so that we can associate replay id, profile id, and a user display to the span + const replay = client.getIntegrationByName string }>('Replay'); + + const replayId = replay && replay.getReplayId(); + + const userDisplay = user !== undefined ? user.email || user.id || user.ip_address : undefined; + const profileId = scope.getScopeData().contexts?.profile?.profile_id as string | undefined; + + const name = htmlTreeAsString(entry.target); + const parentSampled = activeSpan ? spanIsSampled(activeSpan) : undefined; + const attributes: SpanAttributes = dropUndefinedKeys({ + release: options.release, + environment: options.environment, + transaction: routeName, + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: metric.value, + user: userDisplay || undefined, + profile_id: profileId || undefined, + replay_id: replayId || undefined, + }); + + /** Check to see if the span should be sampled */ + const [sampled] = sampleSpan(options, { + name, + parentSampled, + attributes, + transactionContext: { + name, + parentSampled, + }, + }); + + // Nothing to do + if (!sampled) { + return; + } + + const span = new SentrySpan({ + startTimestamp: startTime, + endTimestamp: startTime + duration, + op: `ui.interaction.${interactionType}`, + name, + attributes, + }); + + span.addEvent('inp', { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond', + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: metric.value, + }); + + const envelope = createSpanEnvelope([span]); + const transport = client && client.getTransport(); + if (transport) { + transport.send(envelope).then(null, reason => { + DEBUG_BUILD && logger.error('Error while sending interaction:', reason); + }); + } + return; + }); +} diff --git a/packages/browser-utils/src/metrics/instrument.ts b/packages/browser-utils/src/metrics/instrument.ts index 535f584dddb1..ad390191266f 100644 --- a/packages/browser-utils/src/metrics/instrument.ts +++ b/packages/browser-utils/src/metrics/instrument.ts @@ -3,13 +3,14 @@ import { getFunctionName, logger } from '@sentry/utils'; import { DEBUG_BUILD } from '../debug-build'; import { onCLS } from './web-vitals/getCLS'; import { onFID } from './web-vitals/getFID'; +import { onINP } from './web-vitals/getINP'; import { onLCP } from './web-vitals/getLCP'; import { observe } from './web-vitals/lib/observe'; import { onTTFB } from './web-vitals/onTTFB'; type InstrumentHandlerTypePerformanceObserver = 'longtask' | 'event' | 'navigation' | 'paint' | 'resource'; -type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb'; +type InstrumentHandlerTypeMetric = 'cls' | 'lcp' | 'fid' | 'ttfb' | 'inp'; // We provide this here manually instead of relying on a global, as this is not available in non-browser environements // And we do not want to expose such types @@ -20,6 +21,14 @@ interface PerformanceEntry { readonly startTime: number; toJSON(): Record; } +interface PerformanceEventTiming extends PerformanceEntry { + processingStart: number; + processingEnd: number; + duration: number; + cancelable?: boolean; + target?: unknown | null; + interactionId?: number; +} interface Metric { /** @@ -88,6 +97,7 @@ let _previousCls: Metric | undefined; let _previousFid: Metric | undefined; let _previousLcp: Metric | undefined; let _previousTtfb: Metric | undefined; +let _previousInp: Metric | undefined; /** * Add a callback that will be triggered when a CLS metric is available. @@ -132,9 +142,19 @@ export function addTtfbInstrumentationHandler(callback: (data: { metric: Metric return addMetricObserver('ttfb', callback, instrumentTtfb, _previousTtfb); } +/** + * Add a callback that will be triggered when a INP metric is available. + * Returns a cleanup callback which can be called to remove the instrumentation handler. + */ +export function addInpInstrumentationHandler( + callback: (data: { metric: Omit & { entries: PerformanceEventTiming[] } }) => void, +): CleanupHandlerCallback { + return addMetricObserver('inp', callback, instrumentInp, _previousInp); +} + export function addPerformanceInstrumentationHandler( type: 'event', - callback: (data: { entries: (PerformanceEntry & { target?: unknown | null })[] }) => void, + callback: (data: { entries: ((PerformanceEntry & { target?: unknown | null }) | PerformanceEventTiming)[] }) => void, ): CleanupHandlerCallback; export function addPerformanceInstrumentationHandler( type: InstrumentHandlerTypePerformanceObserver, @@ -217,6 +237,15 @@ function instrumentTtfb(): StopListening { }); } +function instrumentInp(): void { + return onINP(metric => { + triggerHandlers('inp', { + metric, + }); + _previousInp = metric; + }); +} + function addMetricObserver( type: InstrumentHandlerTypeMetric, callback: InstrumentHandlerCallback, diff --git a/packages/browser-utils/src/metrics/utils.ts b/packages/browser-utils/src/metrics/utils.ts index facb3f429e3a..1927f0e9a971 100644 --- a/packages/browser-utils/src/metrics/utils.ts +++ b/packages/browser-utils/src/metrics/utils.ts @@ -1,6 +1,7 @@ import type { SentrySpan } from '@sentry/core'; import { spanToJSON, startInactiveSpan, withActiveSpan } from '@sentry/core'; import type { Span, SpanTimeInput, StartSpanOptions } from '@sentry/types'; +import { WINDOW } from './types'; /** * Checks if a given value is a valid measurement value. @@ -13,9 +14,6 @@ export function isMeasurementValue(value: unknown): value is number { * Helper function to start child on transactions. This function will make sure that the transaction will * use the start timestamp of the created child span if it is earlier than the transactions actual * start timestamp. - * - * Note: this will not be possible anymore in v8, - * unless we do some special handling for browser here... */ export function startAndEndSpan( parentSpan: Span, @@ -45,3 +43,17 @@ export function startAndEndSpan( return span; }); } + +/** Get the browser performance API. */ +export function getBrowserPerformanceAPI(): Performance | undefined { + // @ts-expect-error we want to make sure all of these are available, even if TS is sure they are + return WINDOW && WINDOW.addEventListener && WINDOW.performance; +} + +/** + * Converts from milliseconds to seconds + * @param time time in ms + */ +export function msToSec(time: number): number { + return time / 1000; +} diff --git a/packages/browser/src/profiling/utils.ts b/packages/browser/src/profiling/utils.ts index 4682633d101c..e13c731644b5 100644 --- a/packages/browser/src/profiling/utils.ts +++ b/packages/browser/src/profiling/utils.ts @@ -592,6 +592,9 @@ export function createProfilingEvent( return createProfilePayload(profile_id, start_timestamp, profile, event); } +// TODO (v8): We need to obtain profile ids in @sentry-internal/tracing, +// but we don't have access to this map because importing this map would +// cause a circular dependancy. We need to resolve this in v8. const PROFILE_MAP: Map = new Map(); /** * diff --git a/packages/browser/src/tracing/browserTracingIntegration.ts b/packages/browser/src/tracing/browserTracingIntegration.ts index 7d1561b20995..9485a2118bc4 100644 --- a/packages/browser/src/tracing/browserTracingIntegration.ts +++ b/packages/browser/src/tracing/browserTracingIntegration.ts @@ -1,6 +1,8 @@ +/* eslint-disable max-lines */ import { addHistoryInstrumentationHandler, addPerformanceEntries, + startTrackingINP, startTrackingInteractions, startTrackingLongTasks, startTrackingWebVitals, @@ -94,6 +96,13 @@ export interface BrowserTracingOptions { */ enableLongTask: boolean; + /** + * If true, Sentry will capture first input delay and add it to the corresponding transaction. + * + * Default: true + */ + enableInp: boolean; + /** * Flag to disable patching all together for fetch requests. * @@ -145,6 +154,7 @@ const DEFAULT_BROWSER_TRACING_OPTIONS: BrowserTracingOptions = { instrumentPageLoad: true, markBackgroundSpan: true, enableLongTask: true, + enableInp: true, _experiments: {}, ...defaultRequestInstrumentationOptions, }; @@ -168,6 +178,10 @@ export const browserTracingIntegration = ((_options: Partial(envelopeHeaders, attachmentItems); } + +/** + * Create envelope from Span item. + */ +export function createSpanEnvelope(spans: SentrySpan[]): SpanEnvelope { + const headers: SpanEnvelope[0] = { + sent_at: new Date().toISOString(), + }; + + const items = spans.map(span => createSpanEnvelopeItem(spanToJSON(span))); + return createEnvelope(headers, items); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a863c6ee271d..df889035b7c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,7 +8,7 @@ export type { IntegrationIndex } from './integration'; export * from './tracing'; export * from './semanticAttributes'; -export { createEventEnvelope, createSessionEnvelope, createAttachmentEnvelope } from './envelope'; +export { createEventEnvelope, createSessionEnvelope, createAttachmentEnvelope, createSpanEnvelope } from './envelope'; export { captureCheckIn, withMonitor, @@ -61,7 +61,6 @@ export { export { applyScopeDataToEvent, mergeScopeData } from './utils/applyScopeDataToEvent'; export { prepareEvent } from './utils/prepareEvent'; export { createCheckInEnvelope } from './checkin'; -export { createSpanEnvelope } from './span'; export { hasTracingEnabled } from './utils/hasTracingEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; diff --git a/packages/core/src/semanticAttributes.ts b/packages/core/src/semanticAttributes.ts index aeb4dda815a2..8f46de21a1ca 100644 --- a/packages/core/src/semanticAttributes.ts +++ b/packages/core/src/semanticAttributes.ts @@ -28,3 +28,10 @@ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT = 'sentry.measurement_un /** The value of a measurement, which may be stored as a TimedEvent. */ export const SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE = 'sentry.measurement_value'; + +/** + * The id of the profile that this span occured in. + */ +export const SEMANTIC_ATTRIBUTE_PROFILE_ID = 'sentry.profile_id'; + +export const SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME = 'sentry.exclusive_time'; diff --git a/packages/core/src/span.ts b/packages/core/src/span.ts deleted file mode 100644 index 405ceeddab30..000000000000 --- a/packages/core/src/span.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { SpanEnvelope, SpanItem } from '@sentry/types'; -import type { Span } from '@sentry/types'; -import { createEnvelope } from '@sentry/utils'; - -/** - * Create envelope from Span item. - */ -export function createSpanEnvelope(spans: Span[]): SpanEnvelope { - const headers: SpanEnvelope[0] = { - sent_at: new Date().toISOString(), - }; - - const items = spans.map(createSpanItem); - return createEnvelope(headers, items); -} - -function createSpanItem(span: Span): SpanItem { - const spanHeaders: SpanItem[0] = { - type: 'span', - }; - return [spanHeaders, span]; -} diff --git a/packages/core/src/tracing/measurement.ts b/packages/core/src/tracing/measurement.ts index a8328387c209..817569a03930 100644 --- a/packages/core/src/tracing/measurement.ts +++ b/packages/core/src/tracing/measurement.ts @@ -23,7 +23,11 @@ export function setMeasurement(name: string, value: number, unit: MeasurementUni /** * Convert timed events to measurements. */ -export function timedEventsToMeasurements(events: TimedEvent[]): Measurements { +export function timedEventsToMeasurements(events: TimedEvent[]): Measurements | undefined { + if (!events || events.length === 0) { + return undefined; + } + const measurements: Measurements = {}; events.forEach(event => { const attributes = event.attributes || {}; diff --git a/packages/core/src/tracing/sentrySpan.ts b/packages/core/src/tracing/sentrySpan.ts index c7d436ae3c0a..31fd35bc4b68 100644 --- a/packages/core/src/tracing/sentrySpan.ts +++ b/packages/core/src/tracing/sentrySpan.ts @@ -18,6 +18,8 @@ import { DEBUG_BUILD } from '../debug-build'; import { getMetricSummaryJsonForSpan } from '../metrics/metric-summary'; import { + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_PROFILE_ID, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SEMANTIC_ATTRIBUTE_SENTRY_SOURCE, @@ -183,6 +185,9 @@ export class SentrySpan implements Span { trace_id: this._traceId, origin: this._attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] as SpanOrigin | undefined, _metrics_summary: getMetricSummaryJsonForSpan(this), + profile_id: this._attributes[SEMANTIC_ATTRIBUTE_PROFILE_ID] as string | undefined, + exclusive_time: this._attributes[SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME] as number | undefined, + measurements: timedEventsToMeasurements(this._events), }); } @@ -293,7 +298,7 @@ export class SentrySpan implements Span { }; const measurements = timedEventsToMeasurements(this._events); - const hasMeasurements = Object.keys(measurements).length; + const hasMeasurements = measurements && Object.keys(measurements).length; if (hasMeasurements) { DEBUG_BUILD && diff --git a/packages/opentelemetry/src/spanExporter.ts b/packages/opentelemetry/src/spanExporter.ts index abf82ba8a3f0..f570b9f51859 100644 --- a/packages/opentelemetry/src/spanExporter.ts +++ b/packages/opentelemetry/src/spanExporter.ts @@ -143,7 +143,7 @@ function maybeSend(spans: ReadableSpan[]): ReadableSpan[] { transactionEvent.spans = spans; const measurements = timedEventsToMeasurements(span.events); - if (Object.keys(measurements).length) { + if (measurements) { transactionEvent.measurements = measurements; } @@ -281,6 +281,7 @@ function createAndFinishSpanForOtelSpan(node: SpanNode, spans: SpanJSON[], remai op, origin, _metrics_summary: getMetricSummaryJsonForSpan(span as unknown as Span), + measurements: timedEventsToMeasurements(span.events), }); spans.push(spanJSON); diff --git a/packages/types/src/envelope.ts b/packages/types/src/envelope.ts index c72406b4539f..874edd8d2eda 100644 --- a/packages/types/src/envelope.ts +++ b/packages/types/src/envelope.ts @@ -8,7 +8,7 @@ import type { Profile } from './profiling'; import type { ReplayEvent, ReplayRecordingData } from './replay'; import type { SdkInfo } from './sdkinfo'; import type { SerializedSession, Session, SessionAggregates } from './session'; -import type { Span } from './span'; +import type { SpanJSON } from './span'; // Based on: https://develop.sentry.dev/sdk/envelopes/ @@ -98,7 +98,7 @@ type ReplayRecordingItem = BaseEnvelopeItem; export type FeedbackItem = BaseEnvelopeItem; export type ProfileItem = BaseEnvelopeItem; -export type SpanItem = BaseEnvelopeItem; +export type SpanItem = BaseEnvelopeItem>; export type EventEnvelopeHeaders = { event_id: string; sent_at: string; trace?: DynamicSamplingContext }; type SessionEnvelopeHeaders = { sent_at: string }; diff --git a/packages/types/src/span.ts b/packages/types/src/span.ts index 9b787576b011..066d8e7bf1f3 100644 --- a/packages/types/src/span.ts +++ b/packages/types/src/span.ts @@ -1,3 +1,4 @@ +import type { Measurements } from './measurement'; import type { Primitive } from './misc'; import type { HrTime } from './opentelemetry'; import type { SpanStatus } from './spanStatus'; @@ -54,6 +55,9 @@ export interface SpanJSON { trace_id: string; origin?: SpanOrigin; _metrics_summary?: Record>; + profile_id?: string; + exclusive_time?: number; + measurements?: Measurements; } // These are aligned with OpenTelemetry trace flags diff --git a/packages/utils/src/envelope.ts b/packages/utils/src/envelope.ts index 8b1345b112d7..17c40bed92ad 100644 --- a/packages/utils/src/envelope.ts +++ b/packages/utils/src/envelope.ts @@ -11,6 +11,8 @@ import type { EventEnvelopeHeaders, SdkInfo, SdkMetadata, + SpanItem, + SpanJSON, } from '@sentry/types'; import { dsnToString } from './dsn'; @@ -177,6 +179,17 @@ export function parseEnvelope(env: string | Uint8Array): Envelope { return [envelopeHeader, items]; } +/** + * Creates envelope item for a single span + */ +export function createSpanEnvelopeItem(spanJson: Partial): SpanItem { + const spanHeaders: SpanItem[0] = { + type: 'span', + }; + + return [spanHeaders, spanJson]; +} + /** * Creates attachment envelope items */ diff --git a/packages/utils/test/envelope.test.ts b/packages/utils/test/envelope.test.ts index efb3e873a0a6..6f3a9ea3a6d6 100644 --- a/packages/utils/test/envelope.test.ts +++ b/packages/utils/test/envelope.test.ts @@ -1,8 +1,16 @@ -import type { Event, EventEnvelope } from '@sentry/types'; +import type { Event, EventEnvelope, SpanAttributes } from '@sentry/types'; +import { + SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT, + SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE, + spanToJSON, +} from '@sentry/core'; +import { SentrySpan } from '@sentry/core'; import { addItemToEnvelope, createEnvelope, + createSpanEnvelopeItem, forEachEnvelopeItem, parseEnvelope, serializeEnvelope, @@ -11,6 +19,63 @@ import type { InternalGlobal } from '../src/worldwide'; import { GLOBAL_OBJ } from '../src/worldwide'; describe('envelope', () => { + describe('createSpanEnvelope()', () => { + it('span-envelope-item of INP event has the correct object structure', () => { + const attributes: SpanAttributes = { + release: 'releaseString', + environment: 'dev', + transaction: '/test-route', + [SEMANTIC_ATTRIBUTE_EXCLUSIVE_TIME]: 80, + user: 10, + profile_id: 'test-profile-id', + replay_id: 'test-replay-id', + }; + + const startTime = 1713365480; + + const span = new SentrySpan({ + startTimestamp: startTime, + endTimestamp: startTime + 2, + op: 'ui.interaction.click', + name: '', + attributes, + }); + + span.addEvent('inp', { + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_UNIT]: 'millisecond', + [SEMANTIC_ATTRIBUTE_SENTRY_MEASUREMENT_VALUE]: 100, + }); + + const spanEnvelopeItem = createSpanEnvelopeItem(spanToJSON(span)); + + const expectedObj = { + data: { + 'sentry.origin': expect.any(String), + 'sentry.op': expect.any(String), + release: expect.any(String), + environment: expect.any(String), + transaction: expect.any(String), + 'sentry.exclusive_time': expect.any(Number), + user: expect.any(Number), + profile_id: expect.any(String), + replay_id: expect.any(String), + }, + description: expect.any(String), + op: expect.any(String), + span_id: expect.any(String), + start_timestamp: expect.any(Number), + timestamp: expect.any(Number), + trace_id: expect.any(String), + origin: expect.any(String), + exclusive_time: expect.any(Number), + measurements: { inp: { value: expect.any(Number), unit: expect.any(String) } }, + }; + + expect(spanEnvelopeItem[0].type).toBe('span'); + expect(spanEnvelopeItem[1]).toMatchObject(expectedObj); + }); + }); + describe('createEnvelope()', () => { const testTable: Array<[string, Parameters[0], Parameters[1]]> = [ ['creates an empty envelope', {}, []],