Skip to content

Commit caf71a6

Browse files
committed
fix: context tests, handle UTC, vitals
1 parent 49f3878 commit caf71a6

File tree

6 files changed

+116
-69
lines changed

6 files changed

+116
-69
lines changed

packages/tracker/src/core/tracker.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { HttpClient } from "./client";
2-
import type { BaseEvent, EventContext, TrackerOptions } from "./types";
2+
import type { BaseEvent, EventContext, TrackerOptions, WebVitalEvent } from "./types";
33
import { generateUUIDv4, logger } from "./utils";
44

55
const HEADLESS_CHROME_REGEX = /\bHeadlessChrome\b/i;
@@ -33,15 +33,20 @@ export class BaseTracker {
3333
batchTimer: Timer | null = null;
3434
private isFlushing = false;
3535

36+
// Vitals Queue
37+
vitalsQueue: WebVitalEvent[] = [];
38+
vitalsTimer: Timer | null = null;
39+
private isFlushingVitals = false;
40+
3641
constructor(options: TrackerOptions) {
3742
this.options = {
3843
disabled: false,
3944
trackPerformance: true,
4045
samplingRate: 1.0,
41-
enableRetries: true,
46+
enableRetries: false,
4247
maxRetries: 3,
4348
initialRetryDelay: 500,
44-
enableBatching: false,
49+
enableBatching: true,
4550
batchSize: 10,
4651
batchTimeout: 2000,
4752
sdk: "web",
@@ -391,6 +396,58 @@ export class BaseTracker {
391396
}
392397
}
393398

399+
sendVital(event: WebVitalEvent): Promise<void> {
400+
if (this.shouldSkipTracking()) {
401+
return Promise.resolve();
402+
}
403+
404+
logger.log("Queueing vital", event);
405+
return this.addToVitalsQueue(event);
406+
}
407+
408+
addToVitalsQueue(event: WebVitalEvent): Promise<void> {
409+
this.vitalsQueue.push(event);
410+
if (this.vitalsTimer === null) {
411+
this.vitalsTimer = setTimeout(
412+
() => this.flushVitals(),
413+
this.options.batchTimeout
414+
);
415+
}
416+
if (this.vitalsQueue.length >= 6) {
417+
this.flushVitals();
418+
}
419+
return Promise.resolve();
420+
}
421+
422+
async flushVitals() {
423+
if (this.vitalsTimer) {
424+
clearTimeout(this.vitalsTimer);
425+
this.vitalsTimer = null;
426+
}
427+
if (this.vitalsQueue.length === 0 || this.isFlushingVitals) {
428+
return;
429+
}
430+
431+
this.isFlushingVitals = true;
432+
const vitals = [...this.vitalsQueue];
433+
this.vitalsQueue = [];
434+
435+
logger.log("Flushing vitals", vitals.length);
436+
437+
try {
438+
const result = await this.api.fetch("/vitals", vitals, {
439+
keepalive: true,
440+
});
441+
logger.log("Vitals sent", result);
442+
return result;
443+
} catch (error) {
444+
logger.error("Vitals batch failed", error);
445+
return null;
446+
} finally {
447+
this.isFlushingVitals = false;
448+
}
449+
}
450+
394451
sendBeacon(data: unknown, endpoint = "/vitals"): boolean {
395452
if (this.isServer()) {
396453
return false;

packages/tracker/src/core/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ export type TrackEvent = BaseEvent & {
6666
name: string;
6767
};
6868

69+
export type WebVitalMetricName = "FCP" | "LCP" | "CLS" | "INP" | "TTFB" | "FPS";
70+
71+
export type WebVitalEvent = BaseEvent & {
72+
name: "web_vital";
73+
metricName: WebVitalMetricName;
74+
metricValue: number;
75+
};
76+
6977
export type DatabuddyGlobal = {
7078
track: (name: string, props?: Record<string, unknown>) => void;
7179
screenView: (props?: Record<string, unknown>) => void;

packages/tracker/src/plugins/vitals.ts

Lines changed: 14 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,8 @@
11
import { type Metric, onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
22
import type { BaseTracker } from "../core/tracker";
3+
import type { WebVitalMetricName } from "../core/types";
34
import { logger } from "../core/utils";
45

5-
type WebVitalMetricName = "FCP" | "LCP" | "CLS" | "INP" | "TTFB" | "FPS";
6-
7-
type WebVitalSpan = {
8-
sessionId: string;
9-
timestamp: number;
10-
path: string;
11-
metricName: WebVitalMetricName;
12-
metricValue: number;
13-
};
14-
156
type FPSMetric = {
167
name: "FPS";
178
value: number;
@@ -49,30 +40,24 @@ export function initWebVitalsTracking(tracker: BaseTracker) {
4940

5041
const sentMetrics = new Set<WebVitalMetricName>();
5142

52-
const sendVitalSpan = (metricName: WebVitalMetricName, metricValue: number) => {
53-
if (sentMetrics.has(metricName)) {
43+
const handleMetric = (metric: Metric | FPSMetric) => {
44+
const name = metric.name as WebVitalMetricName;
45+
if (sentMetrics.has(name)) {
5446
return;
5547
}
56-
sentMetrics.add(metricName);
57-
58-
const span: WebVitalSpan = {
59-
sessionId: tracker.sessionId ?? "",
60-
timestamp: Date.now(),
61-
path: window.location.pathname,
62-
metricName,
63-
metricValue,
64-
};
48+
sentMetrics.add(name);
6549

66-
logger.log(`Sending web vital span: ${metricName}`, span);
67-
tracker.sendBeacon(span);
68-
};
69-
70-
const handleMetric = (metric: Metric | FPSMetric) => {
71-
const name = metric.name as WebVitalMetricName;
7250
const value = name === "CLS" ? metric.value : Math.round(metric.value);
73-
7451
logger.log(`Web Vital captured: ${name}`, value);
75-
sendVitalSpan(name, value);
52+
53+
tracker.sendVital({
54+
name: "web_vital",
55+
eventId: crypto.randomUUID(),
56+
timestamp: Date.now(),
57+
path: window.location.pathname,
58+
metricName: name,
59+
metricValue: value,
60+
});
7661
};
7762

7863
onFCP(handleMetric);

packages/tracker/tests/api-methods.spec.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ test.describe("API Methods", () => {
372372
ignoreBotDetection: true,
373373
enableBatching: true,
374374
batchSize: 100, // Large size so it won't auto-flush
375-
batchTimeout: 60000, // Long timeout
375+
batchTimeout: 60_000, // Long timeout
376376
};
377377
});
378378
await page.addScriptTag({ url: "/dist/databuddy.js" });
@@ -418,7 +418,7 @@ test.describe("API Methods", () => {
418418
ignoreBotDetection: true,
419419
enableBatching: true,
420420
batchSize: 100,
421-
batchTimeout: 60000,
421+
batchTimeout: 60_000,
422422
};
423423
});
424424
await page.addScriptTag({ url: "/dist/databuddy.js" });
@@ -505,4 +505,3 @@ test.describe("API Methods", () => {
505505
});
506506
});
507507
});
508-

packages/tracker/tests/context.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ test.describe("Event Context", () => {
109109
const payload = request.postDataJSON();
110110

111111
expect(payload.timezone).toBeTruthy();
112-
// Timezone should be a valid IANA timezone string
113-
expect(payload.timezone).toMatch(/^[A-Za-z_]+\/[A-Za-z_]+$/);
112+
// Timezone should be a valid IANA timezone string (UTC, GMT, or Area/Location format)
113+
expect(payload.timezone).toMatch(/^([A-Za-z_]+\/[A-Za-z_]+|UTC|GMT)$/);
114114
});
115115
});
116116

packages/tracker/tests/vitals.spec.ts

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { expect, test } from "@playwright/test";
22

3-
type VitalSpan = {
4-
sessionId: string;
3+
type WebVitalEvent = {
4+
name: "web_vital";
5+
eventId: string;
56
timestamp: number;
67
path: string;
78
metricName: string;
@@ -25,13 +26,13 @@ test.describe("Web Vitals Tracking", () => {
2526
});
2627
});
2728

28-
test("sends each vital as individual span", async ({ page }) => {
29-
const vitalsReceived: VitalSpan[] = [];
29+
test("batches vitals and sends to /vitals endpoint", async ({ page }) => {
30+
const vitalBatches: WebVitalEvent[][] = [];
3031

3132
page.on("request", (req) => {
3233
if (req.url().includes("/basket.databuddy.cc/vitals") && req.method() === "POST") {
33-
const payload = req.postDataJSON() as VitalSpan;
34-
vitalsReceived.push(payload);
34+
const payload = req.postDataJSON() as WebVitalEvent[];
35+
vitalBatches.push(payload);
3536
}
3637
});
3738

@@ -46,38 +47,34 @@ test.describe("Web Vitals Tracking", () => {
4647
});
4748
await page.addScriptTag({ url: "/dist/vitals.js" });
4849

49-
// Wait for FPS measurement (2 seconds) plus buffer
50-
await page.waitForTimeout(3000);
50+
// Wait for FPS measurement (2 seconds) + batch timeout (2 seconds) + buffer
51+
await page.waitForTimeout(5000);
5152

52-
// Should have received at least one vital span
53-
if (vitalsReceived.length > 0) {
54-
const span = vitalsReceived.at(0);
55-
expect(span?.metricName).toBeDefined();
56-
expect(span?.metricValue).toBeDefined();
57-
expect(span?.sessionId).toBeDefined();
58-
expect(span?.path).toBeDefined();
59-
expect(span?.timestamp).toBeDefined();
53+
if (vitalBatches.length > 0) {
54+
const allVitals = vitalBatches.flat();
55+
expect(allVitals.length).toBeGreaterThan(0);
6056

61-
// Check metric names are valid
6257
const validMetrics = ["FCP", "LCP", "CLS", "INP", "TTFB", "FPS"];
63-
for (const vital of vitalsReceived) {
58+
for (const vital of allVitals) {
59+
expect(vital.name).toBe("web_vital");
60+
expect(vital.eventId).toBeDefined();
6461
expect(validMetrics).toContain(vital.metricName);
6562
expect(typeof vital.metricValue).toBe("number");
6663
}
6764

68-
console.table(vitalsReceived.map((v) => ({ metric: v.metricName, value: v.metricValue })));
65+
console.table(allVitals.map((v) => ({ metric: v.metricName, value: v.metricValue })));
6966
} else {
7067
console.log("No vitals captured - this can happen in test environments");
7168
}
7269
});
7370

7471
test("captures FPS metric", async ({ page }) => {
75-
const vitalsReceived: VitalSpan[] = [];
72+
const vitalBatches: WebVitalEvent[][] = [];
7673

7774
page.on("request", (req) => {
7875
if (req.url().includes("/basket.databuddy.cc/vitals") && req.method() === "POST") {
79-
const payload = req.postDataJSON() as VitalSpan;
80-
vitalsReceived.push(payload);
76+
const payload = req.postDataJSON() as WebVitalEvent[];
77+
vitalBatches.push(payload);
8178
}
8279
});
8380

@@ -92,26 +89,27 @@ test.describe("Web Vitals Tracking", () => {
9289
});
9390
await page.addScriptTag({ url: "/dist/vitals.js" });
9491

95-
// FPS measurement takes 2 seconds
96-
await page.waitForTimeout(3000);
92+
// FPS measurement takes 2 seconds + batch timeout
93+
await page.waitForTimeout(5000);
9794

98-
const fpsVital = vitalsReceived.find((v) => v.metricName === "FPS");
95+
const allVitals = vitalBatches.flat();
96+
const fpsVital = allVitals.find((v) => v.metricName === "FPS");
9997
if (fpsVital) {
10098
expect(fpsVital.metricValue).toBeGreaterThan(0);
101-
expect(fpsVital.metricValue).toBeLessThanOrEqual(120);
99+
expect(fpsVital.metricValue).toBeLessThanOrEqual(240); // High refresh rate displays
102100
console.log("FPS captured:", fpsVital.metricValue);
103101
} else {
104102
console.log("FPS not captured - this can happen in headless browsers");
105103
}
106104
});
107105

108106
test("does not send duplicate metrics", async ({ page }) => {
109-
const vitalsReceived: VitalSpan[] = [];
107+
const vitalBatches: WebVitalEvent[][] = [];
110108

111109
page.on("request", (req) => {
112110
if (req.url().includes("/basket.databuddy.cc/vitals") && req.method() === "POST") {
113-
const payload = req.postDataJSON() as VitalSpan;
114-
vitalsReceived.push(payload);
111+
const payload = req.postDataJSON() as WebVitalEvent[];
112+
vitalBatches.push(payload);
115113
}
116114
});
117115

@@ -126,10 +124,10 @@ test.describe("Web Vitals Tracking", () => {
126124
});
127125
await page.addScriptTag({ url: "/dist/vitals.js" });
128126

129-
await page.waitForTimeout(3000);
127+
await page.waitForTimeout(5000);
130128

131-
// Check no duplicate metric names
132-
const metricNames = vitalsReceived.map((v) => v.metricName);
129+
const allVitals = vitalBatches.flat();
130+
const metricNames = allVitals.map((v) => v.metricName);
133131
const uniqueNames = [...new Set(metricNames)];
134132
expect(metricNames.length).toBe(uniqueNames.length);
135133
});

0 commit comments

Comments
 (0)