Skip to content

Commit d8288b1

Browse files
Vadman97claude
andauthored
feat: emit element-selector attribution on web-vitals metrics (#515)
## Summary Switches the SDK's web-vitals listener from the plain `web-vitals` build to `web-vitals/attribution` so we can attribute each LCP / CLS / INP / FID to a specific DOM element. The selector and a couple of high-signal context fields are emitted as OTel attributes on the gauge metric, closing a feature parity gap with Datadog RUM's `_target_selector`. Attribute keys (kept tight to limit cardinality): - LCP: `web_vital.element`, `web_vital.attribution.url` (sanitized) - CLS: `web_vital.element` (largest-shift target), `web_vital.load_state` - INP: `web_vital.element` (event target), `web_vital.event_type`, `web_vital.load_state` - FID: `web_vital.element`, `web_vital.event_type` - FCP: `web_vital.load_state` - TTFB: no element-level attribute (timing breakdown is already in the value) Both consumers (`sdk/observe.ts` and `client/index.tsx`) get the new attributes; their existing differing shapes (semconv keys vs. plain group/category) are preserved. Bundle-size delta (brotli, the enforced metric): **166.07 kB → 166.99 kB (+0.92 kB)**, well under the 256 kB cap. Gzipped `index.umd.js` grows by ~1.4 kB. ## Test plan - [x] `yarn turbo run build --filter highlight.run` - [x] `yarn turbo run test --filter highlight.run` (406/406 pass) - [x] `yarn enforce-size` (166.99 kB / 256 kB) - [x] `yarn format-check` - [ ] Verify in a real page that gauge metrics now carry `web_vital.element` / `web_vital.event_type` etc. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes how web-vitals metrics are sourced and adds new per-metric attribution fields, which can affect telemetry shape/cardinality and downstream dashboards. Low security risk but does emit additional (sanitized) URL and DOM-selector data. > > **Overview** > Web-vitals collection now uses `web-vitals/attribution` and exposes a typed `WebVitalMetric` union so consumers can safely access per-metric attribution fields. > > Both metric emitters (`client/index.tsx` and `sdk/observe.ts`) now attach **additional web-vitals attributes** (e.g. `web_vital.element`, `web_vital.event_type`, `web_vital.load_state`, and a sanitized `web_vital.attribution.url`) when recording gauges, while keeping each emitter’s existing metric attribute/tag shape intact. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 54e116b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent edefe2c commit d8288b1

3 files changed

Lines changed: 128 additions & 9 deletions

File tree

sdk/highlight-run/src/client/index.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
} from './listeners/jank-listener/jank-listener'
5858
import { HighlightFetchWindow } from './listeners/network-listener/utils/fetch-listener'
5959
import { RequestResponsePair } from './listeners/network-listener/utils/models'
60+
import { sanitizeUrl } from './listeners/network-listener/utils/network-sanitizer'
6061
import { PageVisibilityListener } from './listeners/page-visibility-listener'
6162
import {
6263
PerformanceListener,
@@ -1122,11 +1123,53 @@ SessionSecureID: ${this.sessionData.sessionSecureID}`,
11221123
this.listeners.push(
11231124
WebVitalsListener((data) => {
11241125
const { name, value } = data
1126+
const tags: { name: string; value: string }[] = []
1127+
const addTag = (n: string, v: string | undefined) => {
1128+
if (v) tags.push({ name: n, value: v })
1129+
}
1130+
switch (data.name) {
1131+
case 'LCP': {
1132+
const a = data.attribution
1133+
addTag('web_vital.element', a.element)
1134+
addTag(
1135+
'web_vital.attribution.url',
1136+
a.url ? sanitizeUrl(a.url) : undefined,
1137+
)
1138+
break
1139+
}
1140+
case 'CLS': {
1141+
const a = data.attribution
1142+
addTag('web_vital.element', a.largestShiftTarget)
1143+
addTag('web_vital.load_state', a.loadState)
1144+
break
1145+
}
1146+
case 'INP': {
1147+
const a = data.attribution
1148+
addTag('web_vital.element', a.eventTarget)
1149+
addTag('web_vital.event_type', a.eventType)
1150+
addTag('web_vital.load_state', a.loadState)
1151+
break
1152+
}
1153+
case 'FID': {
1154+
const a = data.attribution
1155+
addTag('web_vital.element', a.eventTarget)
1156+
addTag('web_vital.event_type', a.eventType)
1157+
break
1158+
}
1159+
case 'FCP': {
1160+
const a = data.attribution
1161+
addTag('web_vital.load_state', a.loadState)
1162+
break
1163+
}
1164+
case 'TTFB':
1165+
break
1166+
}
11251167
this.recordGauge({
11261168
name,
11271169
value,
11281170
group: window.location.href,
11291171
category: MetricCategory.WebVital,
1172+
tags: tags.length ? tags : undefined,
11301173
})
11311174
}),
11321175
)

sdk/highlight-run/src/client/listeners/web-vitals-listener/web-vitals-listener.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,34 @@
1-
import { Metric, onCLS, onFCP, onFID, onINP, onLCP, onTTFB } from 'web-vitals'
1+
import {
2+
CLSMetricWithAttribution,
3+
FCPMetricWithAttribution,
4+
FIDMetricWithAttribution,
5+
INPMetricWithAttribution,
6+
LCPMetricWithAttribution,
7+
onCLS,
8+
onFCP,
9+
onFID,
10+
onINP,
11+
onLCP,
12+
onTTFB,
13+
TTFBMetricWithAttribution,
14+
} from 'web-vitals/attribution'
215

3-
export const WebVitalsListener = (callback: (metric: Metric) => void) => {
16+
/**
17+
* Discriminated union of all web-vitals metrics with their per-metric
18+
* attribution shape populated. Use `switch (metric.name)` in consumers to
19+
* narrow to the specific attribution type.
20+
*/
21+
export type WebVitalMetric =
22+
| CLSMetricWithAttribution
23+
| FCPMetricWithAttribution
24+
| FIDMetricWithAttribution
25+
| INPMetricWithAttribution
26+
| LCPMetricWithAttribution
27+
| TTFBMetricWithAttribution
28+
29+
export const WebVitalsListener = (
30+
callback: (metric: WebVitalMetric) => void,
31+
) => {
432
onCLS(callback)
533
onFCP(callback)
634
onFID(callback)

sdk/highlight-run/src/sdk/observe.ts

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -653,16 +653,64 @@ export class ObserveSDK implements Observe {
653653
WebVitalsListener((data) => {
654654
const { name, value } = data
655655
const { hostname, pathname, href } = window.location
656+
const attributes: Attributes = {
657+
group: window.location.pathname,
658+
category: MetricCategory.WebVital,
659+
[SemanticAttributes.ATTR_URL_FULL]: sanitizeUrl(href),
660+
[SemanticAttributes.ATTR_URL_PATH]: pathname,
661+
[SemanticAttributes.ATTR_SERVER_ADDRESS]: hostname,
662+
}
663+
switch (data.name) {
664+
case 'LCP': {
665+
const a = data.attribution
666+
if (a.element) attributes['web_vital.element'] = a.element
667+
if (a.url)
668+
attributes['web_vital.attribution.url'] = sanitizeUrl(
669+
a.url,
670+
)
671+
break
672+
}
673+
case 'CLS': {
674+
const a = data.attribution
675+
if (a.largestShiftTarget)
676+
attributes['web_vital.element'] = a.largestShiftTarget
677+
if (a.loadState)
678+
attributes['web_vital.load_state'] = a.loadState
679+
break
680+
}
681+
case 'INP': {
682+
const a = data.attribution
683+
if (a.eventTarget)
684+
attributes['web_vital.element'] = a.eventTarget
685+
if (a.eventType)
686+
attributes['web_vital.event_type'] = a.eventType
687+
if (a.loadState)
688+
attributes['web_vital.load_state'] = a.loadState
689+
break
690+
}
691+
case 'FID': {
692+
const a = data.attribution
693+
if (a.eventTarget)
694+
attributes['web_vital.element'] = a.eventTarget
695+
if (a.eventType)
696+
attributes['web_vital.event_type'] = a.eventType
697+
break
698+
}
699+
case 'FCP': {
700+
const a = data.attribution
701+
if (a.loadState)
702+
attributes['web_vital.load_state'] = a.loadState
703+
break
704+
}
705+
case 'TTFB':
706+
// No high-signal selector to attribute; timing breakdown
707+
// is already captured by the metric value itself.
708+
break
709+
}
656710
this.recordGauge({
657711
name,
658712
value,
659-
attributes: {
660-
group: window.location.pathname,
661-
category: MetricCategory.WebVital,
662-
[SemanticAttributes.ATTR_URL_FULL]: sanitizeUrl(href),
663-
[SemanticAttributes.ATTR_URL_PATH]: pathname,
664-
[SemanticAttributes.ATTR_SERVER_ADDRESS]: hostname,
665-
},
713+
attributes,
666714
})
667715
})
668716
ViewportResizeListener((viewport: ViewportResizeListenerArgs) => {

0 commit comments

Comments
 (0)