diff --git a/sdk/highlight-run/src/client/index.tsx b/sdk/highlight-run/src/client/index.tsx index 174fd99dab..1fedaea37d 100644 --- a/sdk/highlight-run/src/client/index.tsx +++ b/sdk/highlight-run/src/client/index.tsx @@ -57,6 +57,7 @@ import { } from './listeners/jank-listener/jank-listener' import { HighlightFetchWindow } from './listeners/network-listener/utils/fetch-listener' import { RequestResponsePair } from './listeners/network-listener/utils/models' +import { sanitizeUrl } from './listeners/network-listener/utils/network-sanitizer' import { PageVisibilityListener } from './listeners/page-visibility-listener' import { PerformanceListener, @@ -1122,11 +1123,53 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`, this.listeners.push( WebVitalsListener((data) => { const { name, value } = data + const tags: { name: string; value: string }[] = [] + const addTag = (n: string, v: string | undefined) => { + if (v) tags.push({ name: n, value: v }) + } + switch (data.name) { + case 'LCP': { + const a = data.attribution + addTag('web_vital.element', a.element) + addTag( + 'web_vital.attribution.url', + a.url ? sanitizeUrl(a.url) : undefined, + ) + break + } + case 'CLS': { + const a = data.attribution + addTag('web_vital.element', a.largestShiftTarget) + addTag('web_vital.load_state', a.loadState) + break + } + case 'INP': { + const a = data.attribution + addTag('web_vital.element', a.eventTarget) + addTag('web_vital.event_type', a.eventType) + addTag('web_vital.load_state', a.loadState) + break + } + case 'FID': { + const a = data.attribution + addTag('web_vital.element', a.eventTarget) + addTag('web_vital.event_type', a.eventType) + break + } + case 'FCP': { + const a = data.attribution + addTag('web_vital.load_state', a.loadState) + break + } + case 'TTFB': + break + } this.recordGauge({ name, value, group: window.location.href, category: MetricCategory.WebVital, + tags: tags.length ? tags : undefined, }) }), ) diff --git a/sdk/highlight-run/src/client/listeners/web-vitals-listener/web-vitals-listener.tsx b/sdk/highlight-run/src/client/listeners/web-vitals-listener/web-vitals-listener.tsx index 4d8d8b1d96..8f0bc0dec2 100644 --- a/sdk/highlight-run/src/client/listeners/web-vitals-listener/web-vitals-listener.tsx +++ b/sdk/highlight-run/src/client/listeners/web-vitals-listener/web-vitals-listener.tsx @@ -1,6 +1,34 @@ -import { Metric, onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals' +import { + CLSMetricWithAttribution, + FCPMetricWithAttribution, + FIDMetricWithAttribution, + INPMetricWithAttribution, + LCPMetricWithAttribution, + onCLS, + onFCP, + onFID, + onINP, + onLCP, + onTTFB, + TTFBMetricWithAttribution, +} from 'web-vitals/attribution' -export const WebVitalsListener = (callback: (metric: Metric) => void) => { +/** + * Discriminated union of all web-vitals metrics with their per-metric + * attribution shape populated. Use `switch (metric.name)` in consumers to + * narrow to the specific attribution type. + */ +export type WebVitalMetric = + | CLSMetricWithAttribution + | FCPMetricWithAttribution + | FIDMetricWithAttribution + | INPMetricWithAttribution + | LCPMetricWithAttribution + | TTFBMetricWithAttribution + +export const WebVitalsListener = ( + callback: (metric: WebVitalMetric) => void, +) => { onCLS(callback) onFCP(callback) onFID(callback) diff --git a/sdk/highlight-run/src/sdk/observe.ts b/sdk/highlight-run/src/sdk/observe.ts index 8971525082..cc16ef00a9 100644 --- a/sdk/highlight-run/src/sdk/observe.ts +++ b/sdk/highlight-run/src/sdk/observe.ts @@ -648,16 +648,64 @@ export class ObserveSDK implements Observe { WebVitalsListener((data) => { const { name, value } = data const { hostname, pathname, href } = window.location + const attributes: Attributes = { + group: window.location.pathname, + category: MetricCategory.WebVital, + [SemanticAttributes.ATTR_URL_FULL]: sanitizeUrl(href), + [SemanticAttributes.ATTR_URL_PATH]: pathname, + [SemanticAttributes.ATTR_SERVER_ADDRESS]: hostname, + } + switch (data.name) { + case 'LCP': { + const a = data.attribution + if (a.element) attributes['web_vital.element'] = a.element + if (a.url) + attributes['web_vital.attribution.url'] = sanitizeUrl( + a.url, + ) + break + } + case 'CLS': { + const a = data.attribution + if (a.largestShiftTarget) + attributes['web_vital.element'] = a.largestShiftTarget + if (a.loadState) + attributes['web_vital.load_state'] = a.loadState + break + } + case 'INP': { + const a = data.attribution + if (a.eventTarget) + attributes['web_vital.element'] = a.eventTarget + if (a.eventType) + attributes['web_vital.event_type'] = a.eventType + if (a.loadState) + attributes['web_vital.load_state'] = a.loadState + break + } + case 'FID': { + const a = data.attribution + if (a.eventTarget) + attributes['web_vital.element'] = a.eventTarget + if (a.eventType) + attributes['web_vital.event_type'] = a.eventType + break + } + case 'FCP': { + const a = data.attribution + if (a.loadState) + attributes['web_vital.load_state'] = a.loadState + break + } + case 'TTFB': + // No high-signal selector to attribute; timing breakdown + // is already captured by the metric value itself. + break + } this.recordGauge({ name, value, - attributes: { - group: window.location.pathname, - category: MetricCategory.WebVital, - [SemanticAttributes.ATTR_URL_FULL]: sanitizeUrl(href), - [SemanticAttributes.ATTR_URL_PATH]: pathname, - [SemanticAttributes.ATTR_SERVER_ADDRESS]: hostname, - }, + attributes, }) }) ViewportResizeListener((viewport: ViewportResizeListenerArgs) => {