Skip to content

Commit ca27cae

Browse files
Shukriclaude
authored andcommitted
fix: combine all Web Vitals into one event per page load
Instead of sending LCP/TTFB/PageLoad/INP/CLS as separate events (which creates a new issue for every unique value), all metrics are now collected and sent as a single "Performance vitals" event on pagehide/visibilitychange. The constant message means all page loads group into ONE issue on the dashboard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c0b72fd commit ca27cae

4 files changed

Lines changed: 76 additions & 49 deletions

File tree

src/index.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ export interface DevPulseException {
5454
stacktrace: StackFrame[];
5555
}
5656

57+
export interface WebVitals {
58+
/** Largest Contentful Paint in ms */
59+
lcp?: number;
60+
/** Time to First Byte in ms */
61+
ttfb?: number;
62+
/** Total page load time in ms */
63+
page_load?: number;
64+
/** Interaction to Next Paint in ms */
65+
inp?: number;
66+
/** Cumulative Layout Shift score (0–1, unitless) */
67+
cls?: number;
68+
}
69+
5770
export interface DevPulseEvent {
5871
level: "error" | "warning" | "info";
5972
/** Present on error events */

src/index.js

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {
22
buildFromError,
33
buildFromMessage,
4-
buildFromPerformance,
4+
buildFromVitals,
55
} from "./payload.js";
66
import { Transport } from "./transport.js";
77

@@ -280,26 +280,31 @@ class DevPulseClient {
280280
_trackWebVitals() {
281281
if (!("PerformanceObserver" in window)) return;
282282

283-
// LCP — only report the final value
283+
// Accumulate all metrics; send ONE combined event per page load.
284+
// Keeping the message constant ("Performance vitals") means all page loads
285+
// group into a single issue instead of flooding the list.
286+
const vitals = {};
287+
let sent = false;
288+
289+
const sendVitals = () => {
290+
if (sent || Object.keys(vitals).length === 0) return;
291+
sent = true;
292+
const payload = {
293+
...buildFromVitals(vitals),
294+
environment: this.config.environment,
295+
release: this.config.release,
296+
session_id: this._sessionId,
297+
};
298+
this.transport.send(payload);
299+
};
300+
301+
// LCP — only the final value before page hides
284302
let latestLcp = null;
285303
this._observe("largest-contentful-paint", (entries) => {
286304
latestLcp = entries[entries.length - 1];
287305
});
288306

289-
const sendLcp = () => {
290-
if (latestLcp) {
291-
this.transport.send(buildFromPerformance("LCP", latestLcp.startTime));
292-
latestLcp = null;
293-
}
294-
};
295-
window.addEventListener("pagehide", sendLcp, { once: true });
296-
document.addEventListener(
297-
"visibilitychange",
298-
() => { if (document.visibilityState === "hidden") sendLcp(); },
299-
{ once: true },
300-
);
301-
302-
// INP — Interaction to Next Paint
307+
// INP — worst interaction on the page
303308
let inpValue = 0;
304309
this._observe(
305310
"event",
@@ -310,29 +315,38 @@ class DevPulseClient {
310315
},
311316
{ durationThreshold: 40 },
312317
);
313-
window.addEventListener("pagehide", () => {
314-
if (inpValue > 0) this.transport.send(buildFromPerformance("INP", inpValue));
315-
});
316318

317-
// CLS — Cumulative Layout Shift (unitless)
319+
// CLS — cumulative score (unitless 0–1)
318320
let clsValue = 0;
319321
this._observe("layout-shift", (entries) => {
320322
for (const entry of entries) {
321323
if (!entry.hadRecentInput) clsValue += entry.value;
322324
}
323325
});
324-
window.addEventListener("pagehide", () => {
325-
if (clsValue > 0) this.transport.send(buildFromPerformance("CLS", clsValue, { unit: "" }));
326-
});
327326

328-
// TTFB + PageLoad
327+
// TTFB + PageLoad — available after load event
329328
window.addEventListener("load", () => {
330329
const nav = performance.getEntriesByType("navigation")[0];
331330
if (nav) {
332-
this.transport.send(buildFromPerformance("TTFB", nav.responseStart));
333-
this.transport.send(buildFromPerformance("PageLoad", nav.loadEventEnd));
331+
vitals.ttfb = Math.round(nav.responseStart);
332+
vitals.page_load = Math.round(nav.loadEventEnd);
334333
}
335334
});
335+
336+
// Flush all accumulated vitals on page hide / tab switch
337+
const onHide = () => {
338+
if (latestLcp) vitals.lcp = Math.round(latestLcp.startTime);
339+
if (inpValue > 0) vitals.inp = Math.round(inpValue);
340+
if (clsValue > 0) vitals.cls = +Number(clsValue).toFixed(4);
341+
sendVitals();
342+
};
343+
344+
window.addEventListener("pagehide", onHide, { once: true });
345+
document.addEventListener(
346+
"visibilitychange",
347+
() => { if (document.visibilityState === "hidden") onHide(); },
348+
{ once: true },
349+
);
336350
}
337351

338352
_observe(type, callback, observeOptions = {}) {

src/index.test.js

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest";
22
import {
33
buildFromError,
44
buildFromMessage,
5-
buildFromPerformance,
5+
buildFromVitals,
66
} from "./payload.js";
77

88
describe("buildFromError", () => {
@@ -118,29 +118,27 @@ describe("buildFromMessage", () => {
118118
});
119119
});
120120

121-
describe("buildFromPerformance", () => {
122-
it("rounds ms timing values to whole numbers", () => {
123-
const payload = buildFromPerformance("LCP", 1234.56);
124-
expect(payload.context.performance.value).toBe(1235);
125-
expect(payload.context.performance.unit).toBe("ms");
126-
expect(payload.message).toBe("Performance: LCP = 1235ms");
121+
describe("buildFromVitals", () => {
122+
it("produces a constant message so all page loads group into one issue", () => {
123+
const payload = buildFromVitals({ lcp: 156, ttfb: 59 });
124+
expect(payload.message).toBe("Performance vitals");
125+
expect(payload.level).toBe("info");
126+
expect(payload.platform).toBe("browser");
127127
});
128128

129-
it("preserves 4 decimal places for unitless scores (CLS)", () => {
130-
const payload = buildFromPerformance("CLS", 0.1234, { unit: "" });
131-
expect(payload.context.performance.unit).toBe("");
132-
expect(payload.context.performance.value).toBe(0.1234);
133-
expect(payload.message).toBe("Performance: CLS = 0.1234");
129+
it("embeds all provided vitals under context.vitals", () => {
130+
const payload = buildFromVitals({ lcp: 200, ttfb: 80, page_load: 900, inp: 120, cls: 0.05 });
131+
expect(payload.context.vitals).toEqual({ lcp: 200, ttfb: 80, page_load: 900, inp: 120, cls: 0.05 });
134132
});
135133

136-
it("includes context and request", () => {
137-
const payload = buildFromPerformance("TTFB", 200);
134+
it("includes context.url and request.url", () => {
135+
const payload = buildFromVitals({ ttfb: 50 });
138136
expect(payload.context).toHaveProperty("url");
139137
expect(payload.request).toHaveProperty("url");
140138
});
141139

142140
it("request includes method and referrer fields", () => {
143-
const payload = buildFromMessage("test");
141+
const payload = buildFromVitals({ lcp: 100 });
144142
expect(payload.request).toHaveProperty("method");
145143
expect(payload.request).toHaveProperty("referrer");
146144
});

src/payload.js

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,20 @@ export function buildFromMessage(message, level = "info", options = {}) {
2525
};
2626
}
2727

28-
// unit defaults to "ms" for timing metrics; pass { unit: "" } for unitless
29-
// scores like CLS which are in the 0–1 range, not milliseconds.
30-
export function buildFromPerformance(name, value, options = {}) {
31-
const unit = options.unit ?? "ms";
32-
const displayValue =
33-
unit === "ms" ? Math.round(value) : +Number(value).toFixed(4);
28+
/**
29+
* Build a single combined vitals event for one page load.
30+
* All metrics are nested under context.vitals so the message stays
31+
* constant ("Performance vitals") and all events group into one issue.
32+
*
33+
* @param {object} vitals — e.g. { lcp: 156, ttfb: 59, page_load: 820, inp: 120, cls: 0.01 }
34+
*/
35+
export function buildFromVitals(vitals) {
3436
return {
3537
level: "info",
36-
message: `Performance: ${name} = ${displayValue}${unit}`,
38+
message: "Performance vitals",
3739
context: {
3840
...buildContext(),
39-
performance: { name, value: displayValue, unit },
41+
vitals,
4042
},
4143
request: buildRequest(),
4244
platform: "browser",

0 commit comments

Comments
 (0)