Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/web-vitals-rum.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@smooai/observability': minor
---

Add `installWebVitals(metrics, opts?)` browser RUM one-liner. Wires the
`web-vitals` library into a Smoo `MetricsClient` to record Core Web Vitals
(LCP/CLS/INP/FCP/TTFB) as `web.vitals.*` metrics with `route` / `rating` /
`navigation_type` attributes. Browser-guarded, idempotent, lazy-loads
`web-vitals` only when called. Exported from `@smooai/observability/browser`.
5 changes: 3 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@
"lint": "echo \"(lint stub — biome/eslint TBD)\""
},
"dependencies": {
"@smooai/fetch": "^3.3.10",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/core": "^1.30.0",
Expand All @@ -78,7 +77,9 @@
"@opentelemetry/resources": "^1.30.0",
"@opentelemetry/sdk-metrics": "^1.30.0",
"@opentelemetry/sdk-node": "^0.55.0",
"@opentelemetry/semantic-conventions": "^1.30.0"
"@opentelemetry/semantic-conventions": "^1.30.0",
"@smooai/fetch": "^3.3.10",
"web-vitals": "^5.3.0"
},
"peerDependencies": {
"next": ">=14",
Expand Down
71 changes: 71 additions & 0 deletions packages/core/src/browser/__tests__/web-vitals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import type { MetricsClient } from '../../metrics';
import { recordWebVital } from '../web-vitals';

/**
* Drive the pinned web.vitals.* contract with a fake MetricsClient so we can
* assert the exact metric name / recording kind / attributes for a simulated
* vital — no real browser or PerformanceObserver needed.
*/
type Recorded = { kind: 'timing' | 'histogram'; name: string; value: number; attrs?: Record<string, string> };

function fakeMetrics(): { client: MetricsClient; recorded: Recorded[] } {
const recorded: Recorded[] = [];
const client: MetricsClient = {
counter: () => {},
histogram: (name, value, attrs) => recorded.push({ kind: 'histogram', name, value, attrs }),
timing: (name, ms, attrs) => recorded.push({ kind: 'timing', name, value: ms, attrs }),
startTimer: () => () => {},
withTiming: async (_name, fn) => fn(),
};
return { client, recorded };
}

const metric = (name: string, value: number) => ({
name: name as never,
value,
rating: 'good' as const,
navigationType: 'navigate',
});

describe('recordWebVital', () => {
it('records LCP/FCP/INP/TTFB as `ms` timings under the pinned names', () => {
const cases: Array<[string, string]> = [
['LCP', 'web.vitals.lcp'],
['FCP', 'web.vitals.fcp'],
['INP', 'web.vitals.inp'],
['TTFB', 'web.vitals.ttfb'],
];
for (const [vital, expectedName] of cases) {
const { client, recorded } = fakeMetrics();
recordWebVital(client, metric(vital, 1234), '/pricing');
expect(recorded).toEqual([
{
kind: 'timing',
name: expectedName,
value: 1234,
attrs: { route: '/pricing', rating: 'good', navigation_type: 'navigate' },
},
]);
}
});

it('records CLS as a unitless histogram with the raw value', () => {
const { client, recorded } = fakeMetrics();
recordWebVital(client, metric('CLS', 0.042), '/');
expect(recorded).toEqual([
{
kind: 'histogram',
name: 'web.vitals.cls',
value: 0.042,
attrs: { route: '/', rating: 'good', navigation_type: 'navigate' },
},
]);
});

it('propagates rating and navigation_type from the metric', () => {
const { client, recorded } = fakeMetrics();
recordWebVital(client, { name: 'LCP' as never, value: 5000, rating: 'poor', navigationType: 'back-forward' }, '/slow');
expect(recorded[0]!.attrs).toEqual({ route: '/slow', rating: 'poor', navigation_type: 'back-forward' });
});
});
1 change: 1 addition & 0 deletions packages/core/src/browser/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export { registerBrowserGlobalHandlers } from './global-handlers';
export { installFetchBreadcrumbs, installNavigationBreadcrumbs } from './breadcrumbs';
export { installConsoleErrorTap } from './console-tap';
export { makeBrowserTransport } from './transport';
export { installWebVitals, recordWebVital, type InstallWebVitalsOptions } from './web-vitals';

// Auto-wire on init. The Client.init implementation lives in core/client.ts
// and stores options; we hook into it here by re-defining the init behavior
Expand Down
96 changes: 96 additions & 0 deletions packages/core/src/browser/web-vitals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Core Web Vitals RUM one-liner for the browser.
*
* Wires the `web-vitals` library's field-data callbacks into a Smoo
* `MetricsClient` so a customer gets LCP/CLS/INP/FCP/TTFB recorded as
* metrics with a single call:
*
* ```ts
* import { getMetricsClient } from '@smooai/observability/metrics';
* import { installWebVitals } from '@smooai/observability/browser';
*
* installWebVitals(getMetricsClient('my-web-app'));
* ```
*
* PINNED metric contract (must match the apps/web dogfood):
* - web.vitals.lcp / .fcp / .inp / .ttfb — histogram, unit `ms`
* - web.vitals.cls — histogram, unitless raw CLS
* - attributes on every point: route, rating, navigation_type
*/
import type { MetricsClient } from '../metrics';

/** Minimal shape of a `web-vitals` Metric — only the fields we read. */
interface WebVitalMetric {
name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB';
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
navigationType: string;
}

// web-vitals metric name → our `ms` metric name. CLS is handled separately
// (unitless float) so it's intentionally absent here.
const MS_VITAL_NAMES: Record<string, string> = {
LCP: 'web.vitals.lcp',
FCP: 'web.vitals.fcp',
INP: 'web.vitals.inp',
TTFB: 'web.vitals.ttfb',
};

export interface InstallWebVitalsOptions {
/**
* Resolve the `route` attribute at report time. Defaults to
* `location.pathname`. Override for SPA routers that rewrite the path
* client-side (e.g. return your router's current route).
*/
route?: () => string;
}

/**
* Map one web-vitals Metric onto the pinned metric contract and record it.
* Exported so tests can drive it with a synthetic metric without a browser.
*/
export function recordWebVital(metrics: MetricsClient, metric: WebVitalMetric, route: string): void {
const attrs = {
route,
rating: metric.rating,
navigation_type: metric.navigationType,
};
if (metric.name === 'CLS') {
// Unitless raw CLS — plain histogram, not a `ms` timing.
metrics.histogram('web.vitals.cls', metric.value, attrs);
} else {
// Safe: the CLS branch above is the only key absent from MS_VITAL_NAMES.
metrics.timing(MS_VITAL_NAMES[metric.name]!, metric.value, attrs);
}
}

let installed = false;

/**
* Start recording Core Web Vitals into `metrics`. Browser-only and
* idempotent — safe to call unconditionally on every page load; a no-op
* during SSR and on repeat calls.
*/
export function installWebVitals(metrics: MetricsClient, opts: InstallWebVitalsOptions = {}): void {
if (typeof window === 'undefined') return; // ponytail: SSR / no-DOM no-op
if (installed) return;
installed = true;

const route = opts.route ?? (() => window.location.pathname);
const report = (metric: WebVitalMetric) => recordWebVital(metrics, metric, route());

// Lazy import so web-vitals only loads (and its PerformanceObserver only
// registers) once a consumer opts in.
void import('web-vitals').then(({ onLCP, onCLS, onINP, onFCP, onTTFB }) => {
onLCP(report);
onCLS(report);
onINP(report);
onFCP(report);
onTTFB(report);
});
}

/** Test seam — reset the idempotency guard between cases. */
export function _resetWebVitalsInstalledForTests(): void {
installed = false;
}
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading