Skip to content

Add web vitals telemetry bridge#95

Merged
shayancoin merged 2 commits intomainfrom
codex/implement-web-vitals-tracking-and-api
Oct 17, 2025
Merged

Add web vitals telemetry bridge#95
shayancoin merged 2 commits intomainfrom
codex/implement-web-vitals-tracking-and-api

Conversation

@shayancoin
Copy link
Owner

@shayancoin shayancoin commented Oct 16, 2025

Summary

  • add a client-side web vitals collector that batches metrics and navigation timings
  • forward collected payloads through a new OTLP bridge API route
  • document required collector environment variables and add dependencies for telemetry

Testing

  • npm run lint

https://chatgpt.com/codex/tasks/task_e_68f12bf585e083309c9bc07f5caf8d57

Summary by CodeRabbit

  • New Features

    • App now collects and batches web performance metrics (Core Web Vitals and navigation timing) and reliably sends them to a configured observability endpoint.
  • Documentation

    • Added an observability configuration guide describing collector settings, environment variables, and local vs production deployment notes.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Caution

Review failed

The pull request is closed.

Walkthrough

Adds frontend observability: client-side web-vitals collection and batching, a new Next.js API route that converts metrics into OTLP spans and forwards them to a configured collector, README observability docs, dependency updates, and providers integration (with a duplicated import present).

Changes

Cohort / File(s) Summary
Documentation
frontend/README.md
Adds an "Observability configuration" section describing browser web vitals batching, forwarding to an OpenTelemetry collector, and required env vars (OTLP host, port, protocol, path, service name) with local vs production guidance.
Dependencies
frontend/package.json
Removes @react-three/drei and @react-three/fiber; adds @opentelemetry/api, web-vitals, and zustand.
Client Web Vitals
frontend/src/app/web-vitals.ts
New client module that captures CLS/FCP/INP/LCP/TTFB and navigation timing, batches metrics (flush interval ~5s), sanitizes attribution, sends via navigator.sendBeacon or fetch (keepalive), re-queues on failure, exposes initWebVitals() and flushWebVitalsQueue().
Provider Integration
frontend/src/app/providers.tsx
Calls initWebVitals() from a useEffect on mount; file contains duplicate/stray import lines for useEffect and initWebVitals.
OTLP API Endpoint
frontend/src/app/api/otel/webvital/route.ts
New POST route that accepts JSON web-vital/navigation records, transforms them into OTLP-compatible span objects (nanosecond timestamps, attributes map, trace/span IDs), assembles an OtelTraceExport payload enriched with service/environment metadata, forwards to configured OTLP collector URL, and returns 202/502 with per-record error resilience and logging.

Sequence Diagram(s)

sequenceDiagram
    participant Browser
    participant Providers as Providers Component
    participant WebVitals as web-vitals Module
    participant Queue as Batch Queue
    participant API as /api/otel/webvital
    participant Collector as OTLP Collector

    Browser->>Providers: Mount
    Providers->>WebVitals: initWebVitals()
    WebVitals->>WebVitals: Register web-vitals & navigation capture

    rect rgb(220,240,255)
        Browser->>WebVitals: Emit metric / navigation entries
        WebVitals->>Queue: Enqueue payloads
    end

    rect rgb(220,255,220)
        WebVitals->>WebVitals: Flush interval or lifecycle event
        alt navigator.sendBeacon available
            Queue->>API: POST (sendBeacon)
        else
            Queue->>API: POST (fetch keepalive)
        end
    end

    rect rgb(255,245,230)
        API->>API: Transform records -> OTLP spans (ns timestamps)
        API->>Collector: POST OTLP payload
        alt Success
            Collector-->>API: 2xx
            API-->>Browser: 202 Accepted
        else Failure
            Collector-->>API: non-2xx / network error
            API-->>Browser: 502 Bad Gateway
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hop and tally frames that roam,

Batched and beaconed from my home,
Spans take flight to OTLP seas,
Metrics whisper on the breeze,
Hooray — observability for thee!

Pre-merge checks and finishing touches

❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Description Check ⚠️ Warning The pull request description does not follow the required template because it omits the PR Type and Short Description sections, uses non-standard headings instead of the specified names, and fails to include a Tests Added section, leaving out key metadata needed for consistent context during review. Please update the description to include the PR Type, Short Description, and Tests Added sections in accordance with the repository template so that all required information is clearly presented.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (1 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly summarizes the primary change by stating that a web vitals telemetry bridge is being added and is concise without extraneous details, directly reflecting the pull request’s main purpose and making it easy for team members to understand at a glance.

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f58ed1e and 6d0bc52.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (2)
  • frontend/package.json (1 hunks)
  • frontend/src/app/providers.tsx (1 hunks)

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

📜 Review details

Configuration used: CodeRabbit UI

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d2a35c4 and f58ed1e.

⛔ Files ignored due to path filters (1)
  • frontend/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (5)
  • frontend/README.md (1 hunks)
  • frontend/package.json (1 hunks)
  • frontend/src/app/api/otel/webvital/route.ts (1 hunks)
  • frontend/src/app/providers.tsx (2 hunks)
  • frontend/src/app/web-vitals.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
frontend/src/app/providers.tsx (1)
frontend/src/app/web-vitals.ts (1)
  • initWebVitals (226-240)
🪛 LanguageTool
frontend/README.md

[grammar] ~32-~32: Use correct spacing
Context: ...entions. ## Observability configuration Web vitals recorded in the browser are b...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~34-~34: Use correct spacing
Context: ...s are required for the forwarding route: | Variable | Description | Default | | -...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~42-~42: Use correct spacing
Context: ...ed in exported spans. | frontend-web | When running locally, add these variable...

(QB_NEW_EN_OTHER_ERROR_IDS_5)

🔇 Additional comments (3)
frontend/src/app/web-vitals.ts (1)

103-147: SPA soft navigations are not captured with performance.getEntriesByType('navigation').

This only captures hard navigations. For SPA route changes, consider:

  • using the web‑vitals soft‑navs build,
  • or instrumenting router transitions to enqueue synthetic navigation records.

Would you like me to add soft‑navigation support (Next.js router integration or switch to web-vitals/attribution/soft-navs)?

frontend/src/app/providers.tsx (1)

24-27: LGTM — initializing web vitals on mount is correct and idempotent.

frontend/src/app/api/otel/webvital/route.ts (1)

350-357: Content-Type and endpoint are correct
OpenTelemetry Collector accepts JSON‐encoded OTLP on /v1/traces with Content-Type: application/json; no changes required.

Comment on lines 32 to 44
## Observability configuration

Web vitals recorded in the browser are batched to `/api/otel/webvital` and forwarded to an OpenTelemetry collector. The following environment variables are required for the forwarding route:

| Variable | Description | Default |
| --- | --- | --- |
| `OTEL_EXPORTER_OTLP_HTTP_HOST` | Hostname or IP address of the OTLP collector. | _required_ |
| `OTEL_EXPORTER_OTLP_HTTP_PORT` | Port exposed by the OTLP collector. | _empty_ |
| `OTEL_EXPORTER_OTLP_HTTP_PROTOCOL` | Protocol to use when contacting the collector (`http` or `https`). | `http` |
| `OTEL_EXPORTER_OTLP_HTTP_PATH` | Path on the collector that accepts OTLP trace payloads. | `/v1/traces` |
| `OTEL_SERVICE_NAME` | Overrides the service name reported in exported spans. | `frontend-web` |

When running locally, add these variables to the environment before starting `npm run dev`. In production, ensure the deployment platform injects the same values.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Polish markdown spacing and clarify OTLP/HTTP JSON support.

  • Add blank lines before the heading and before the table for consistent rendering.
  • Note that many collectors expect OTLP/HTTP Protobuf by default; if using JSON, ensure the collector is configured to accept application/json on /v1/traces.

Would you confirm your OTLP collector is configured to accept OTLP/HTTP JSON on /v1/traces with Content-Type: application/json?

🧰 Tools
🪛 LanguageTool

[grammar] ~32-~32: Use correct spacing
Context: ...entions. ## Observability configuration Web vitals recorded in the browser are b...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~34-~34: Use correct spacing
Context: ...s are required for the forwarding route: | Variable | Description | Default | | -...

(QB_NEW_EN_OTHER_ERROR_IDS_5)


[grammar] ~42-~42: Use correct spacing
Context: ...ed in exported spans. | frontend-web | When running locally, add these variable...

(QB_NEW_EN_OTHER_ERROR_IDS_5)

🤖 Prompt for AI Agents
In frontend/README.md around lines 32 to 44, the Observability configuration
section needs markdown polishing and a clarification about OTLP/HTTP JSON
support: add a blank line before the "## Observability configuration" heading
and another blank line before the environment variables table so the heading and
table render consistently, and update the prose to explicitly state that many
collectors expect OTLP/HTTP Protobuf by default and that if you intend to send
OTLP/HTTP JSON you must configure the collector to accept application/json on
/v1/traces (and set the appropriate Content-Type header).


export const runtime = 'nodejs'

diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Align diag log level with usage.

You call diag.warn(...) but set level to ERROR, so warns won’t emit. Either change level to WARN or drop warn calls.

-diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
+diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.ERROR)
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.WARN)
🤖 Prompt for AI Agents
In frontend/src/app/api/otel/webvital/route.ts around line 7, the diag logger is
set to DiagLogLevel.ERROR while the code uses diag.warn(...), so warnings are
suppressed; change the logger level to DiagLogLevel.WARN (or DiagLogLevel.INFO
if broader visibility is desired) so diag.warn calls will emit, and remove any
redundant warn calls if you prefer to keep ERROR level.

Comment on lines 299 to 373
export async function POST(request: NextRequest) {
const collectorUrl = getCollectorUrl()
if (!collectorUrl) {
return NextResponse.json(
{ error: 'OTLP collector host is not configured' },
{ status: 503 }
)
}

let payload: IncomingPayload
try {
payload = await request.json()
} catch (error) {
diag.warn('Invalid web vital payload received', error)
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}

if (!payload.metrics || !Array.isArray(payload.metrics) || payload.metrics.length === 0) {
return NextResponse.json({ forwarded: 0 }, { status: 202 })
}

const spans: OtelSpan[] = []
for (const record of payload.metrics) {
if (!record || typeof record !== 'object') {
continue
}

if ((record as WebVitalRecord).type === 'web-vital') {
try {
spans.push(createMetricSpan(record as WebVitalRecord))
} catch (error) {
diag.warn('Failed to transform web vital metric', error)
}
continue
}

if ((record as NavigationRecord).type === 'navigation') {
try {
spans.push(createNavigationSpan(record as NavigationRecord))
} catch (error) {
diag.warn('Failed to transform navigation timing', error)
}
}
}

if (spans.length === 0) {
return NextResponse.json({ forwarded: 0 }, { status: 202 })
}

const otlpPayload = buildExport(spans)

try {
const response = await fetch(collectorUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(otlpPayload),
})

if (!response.ok) {
const text = await response.text()
diag.error('Collector rejected web vital payload', response.status, text)
return NextResponse.json(
{ error: 'Collector rejected payload', status: response.status },
{ status: 502 }
)
}
} catch (error) {
diag.error('Failed to reach OTLP collector', error)
return NextResponse.json({ error: 'Failed to reach collector' }, { status: 502 })
}

return NextResponse.json({ forwarded: spans.length }, { status: 202 })
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add basic abuse protection (same‑origin check or ingest token).

This publicly reachable endpoint can be spammed to relay arbitrary spans to your collector. Add a lightweight guard.

Example same‑origin check (allow internal calls only):

 export async function POST(request: NextRequest) {
+  // Basic anti-abuse: only accept same-origin requests
+  const origin = request.headers.get('origin')
+  if (origin) {
+    try {
+      const originHost = new URL(origin).host
+      const reqHost = request.nextUrl.host
+      if (originHost !== reqHost) {
+        return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+      }
+    } catch {
+      return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
+    }
+  }

Alternatively, require a shared secret:

-  const collectorUrl = getCollectorUrl()
+  const collectorUrl = getCollectorUrl()
   if (!collectorUrl) {
@@
+  const token = request.headers.get('x-otel-ingest-token')
+  if ((process.env.OTEL_INGEST_TOKEN ?? '') && token !== process.env.OTEL_INGEST_TOKEN) {
+    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function POST(request: NextRequest) {
const collectorUrl = getCollectorUrl()
if (!collectorUrl) {
return NextResponse.json(
{ error: 'OTLP collector host is not configured' },
{ status: 503 }
)
}
let payload: IncomingPayload
try {
payload = await request.json()
} catch (error) {
diag.warn('Invalid web vital payload received', error)
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
if (!payload.metrics || !Array.isArray(payload.metrics) || payload.metrics.length === 0) {
return NextResponse.json({ forwarded: 0 }, { status: 202 })
}
const spans: OtelSpan[] = []
for (const record of payload.metrics) {
if (!record || typeof record !== 'object') {
continue
}
if ((record as WebVitalRecord).type === 'web-vital') {
try {
spans.push(createMetricSpan(record as WebVitalRecord))
} catch (error) {
diag.warn('Failed to transform web vital metric', error)
}
continue
}
if ((record as NavigationRecord).type === 'navigation') {
try {
spans.push(createNavigationSpan(record as NavigationRecord))
} catch (error) {
diag.warn('Failed to transform navigation timing', error)
}
}
}
if (spans.length === 0) {
return NextResponse.json({ forwarded: 0 }, { status: 202 })
}
const otlpPayload = buildExport(spans)
try {
const response = await fetch(collectorUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(otlpPayload),
})
if (!response.ok) {
const text = await response.text()
diag.error('Collector rejected web vital payload', response.status, text)
return NextResponse.json(
{ error: 'Collector rejected payload', status: response.status },
{ status: 502 }
)
}
} catch (error) {
diag.error('Failed to reach OTLP collector', error)
return NextResponse.json({ error: 'Failed to reach collector' }, { status: 502 })
}
return NextResponse.json({ forwarded: spans.length }, { status: 202 })
}
export async function POST(request: NextRequest) {
// Basic anti-abuse: only accept same-origin requests
const origin = request.headers.get('origin')
if (origin) {
try {
const originHost = new URL(origin).host
const reqHost = request.nextUrl.host
if (originHost !== reqHost) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
} catch {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
const collectorUrl = getCollectorUrl()
if (!collectorUrl) {
return NextResponse.json(
{ error: 'OTLP collector host is not configured' },
{ status: 503 }
)
}
// Require shared ingest token if configured
const token = request.headers.get('x-otel-ingest-token')
if ((process.env.OTEL_INGEST_TOKEN ?? '') && token !== process.env.OTEL_INGEST_TOKEN) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
let payload: IncomingPayload
try {
payload = await request.json()
} catch (error) {
diag.warn('Invalid web vital payload received', error)
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
}
if (!payload.metrics || !Array.isArray(payload.metrics) || payload.metrics.length === 0) {
return NextResponse.json({ forwarded: 0 }, { status: 202 })
}
const spans: OtelSpan[] = []
for (const record of payload.metrics) {
if (!record || typeof record !== 'object') {
continue
}
if ((record as WebVitalRecord).type === 'web-vital') {
try {
spans.push(createMetricSpan(record as WebVitalRecord))
} catch (error) {
diag.warn('Failed to transform web vital metric', error)
}
continue
}
if ((record as NavigationRecord).type === 'navigation') {
try {
spans.push(createNavigationSpan(record as NavigationRecord))
} catch (error) {
diag.warn('Failed to transform navigation timing', error)
}
}
}
if (spans.length === 0) {
return NextResponse.json({ forwarded: 0 }, { status: 202 })
}
const otlpPayload = buildExport(spans)
try {
const response = await fetch(collectorUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(otlpPayload),
})
if (!response.ok) {
const text = await response.text()
diag.error('Collector rejected web vital payload', response.status, text)
return NextResponse.json(
{ error: 'Collector rejected payload', status: response.status },
{ status: 502 }
)
}
} catch (error) {
diag.error('Failed to reach OTLP collector', error)
return NextResponse.json({ error: 'Failed to reach collector' }, { status: 502 })
}
return NextResponse.json({ forwarded: spans.length }, { status: 202 })
}
🤖 Prompt for AI Agents
In frontend/src/app/api/otel/webvital/route.ts around lines 299 to 373 the
public POST endpoint accepts arbitrary spans and can be abused to relay spam to
the OTLP collector; add a lightweight guard by checking either same-origin or a
shared ingest token before processing the body: read Origin and/or Referer and
reject requests not matching your allowed internal host(s) with a 403, or read a
custom header (e.g. x-ingest-token) and verify it against a secret stored in env
(use a constant-time compare) and return 401/403 when missing/invalid; log
rejected attempts and short-circuit before parsing the body or forwarding to the
collector so you avoid work on abusive requests.

Comment on lines 56 to 96
async function flushQueue() {
if (queue.length === 0) {
return
}

const batch = queue
queue = []

const body = JSON.stringify({
sentAt: new Date().toISOString(),
metrics: batch,
})

try {
if (navigator.sendBeacon) {
const blob = new Blob([body], { type: 'application/json' })
const queued = navigator.sendBeacon(REPORT_ENDPOINT, blob)
if (!queued) {
await fetch(REPORT_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
})
}
return
}

await fetch(REPORT_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
keepalive: true,
})
} catch (error) {
if (process.env.NODE_ENV !== 'production') {
console.warn('Failed to report web vitals', error)
}
queue.unshift(...batch)
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Bound queue growth and chunk payloads to avoid keepalive/sendBeacon drops.

Current batching can grow unbounded and exceed typical ~64KB limits for navigator.sendBeacon/fetch keepalive, causing silent drops and infinite re-queue loops.

Apply limits and chunking, e.g.:

 const FLUSH_INTERVAL_MS = 5000
+const MAX_QUEUE_SIZE = 200
+const MAX_BATCH_ITEMS = 50
+const MAX_PAYLOAD_BYTES = 60000 // conservative ~60KB

 function scheduleFlush() {
@@
 async function flushQueue() {
   if (queue.length === 0) {
     return
   }
-
-  const batch = queue
-  queue = []
+  // Drain up to MAX_BATCH_ITEMS while respecting payload byte limits.
+  const batch: Payload[] = []
+  let estimatedBytes = 32 // envelope overhead
+  while (queue.length && batch.length < MAX_BATCH_ITEMS) {
+    const next = queue.shift() as Payload
+    const nextSize = JSON.stringify(next).length + 1
+    if (estimatedBytes + nextSize > MAX_PAYLOAD_BYTES && batch.length > 0) {
+      // Put it back for next flush if it would overflow
+      queue.unshift(next)
+      break
+    }
+    estimatedBytes += nextSize
+    batch.push(next)
+  }
@@
   } catch (error) {
@@
-    queue.unshift(...batch)
+    // Prepend failed batch, but cap overall queue size to prevent OOM
+    queue = [...batch, ...queue].slice(0, MAX_QUEUE_SIZE)
   }
 }

Also cap enqueue:

 function enqueue(payload: Payload) {
-  queue.push(payload)
+  if (queue.length < MAX_QUEUE_SIZE) {
+    queue.push(payload)
+  }
   scheduleFlush()
 }
🤖 Prompt for AI Agents
In frontend/src/app/web-vitals.ts around lines 56 to 96, the current flushQueue
implementation can build unbounded queues and send payloads that exceed
sendBeacon/fetch keepalive limits, leading to silent drops and infinite re-queue
loops; change it to: cap the total queued items (e.g. MAX_QUEUE_ITEMS) when
enqueueing by dropping oldest/newest as desired, compute JSON byte size per
metric (e.g. Buffer.byteLength or string.length heuristic) and split the batch
into chunks that do not exceed a MAX_PAYLOAD_BYTES (≈60KB) before sending,
iterate over chunks and for each try navigator.sendBeacon (with Blob) and
fallback to fetch keepalive; on per-chunk failure push only that chunk back to
the front of the queue but ensure queue size limit is still enforced to avoid
infinite growth. Ensure all send paths stop re-queuing the entire original batch
and use chunk-level retries only.

Comment on lines 199 to 209
function registerWebVitals() {
const report = (metric: Metric & { rating?: string }) => {
enqueue(createMetricPayload(metric))
}

onCLS(report)
onFCP(report)
onINP(report)
onLCP(report)
onTTFB(report)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Use attribution/soft‑navs builds and report all changes for richer insights.

  • Prefer web-vitals/attribution (or web-vitals/attribution/soft-navs for SPAs) to guarantee metric.attribution.
  • Pass { reportAllChanges: true } for CLS/INP to capture updates after first report.

Example:

-import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals'
+import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals/attribution' // or '.../attribution/soft-navs'

 function registerWebVitals() {
-  const report = (metric: Metric & { rating?: string }) => {
+  const report = (metric: Metric & { rating?: string }) => {
     enqueue(createMetricPayload(metric))
   }
-  onCLS(report)
-  onFCP(report)
-  onINP(report)
-  onLCP(report)
-  onTTFB(report)
+  onCLS(report, { reportAllChanges: true })
+  onFCP(report)
+  onINP(report, { reportAllChanges: true })
+  onLCP(report)
+  onTTFB(report)
 }

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In frontend/src/app/web-vitals.ts around lines 199 to 209, the current
registerWebVitals uses the default web-vitals imports and calls onCLS/onINP
without options, so metric.attribution may be missing and subsequent CLS/INP
updates are not reported; update imports to use web-vitals/attribution or
web-vitals/attribution/soft-navs for SPA builds so Metric includes attribution,
and change the calls to onCLS and onINP to pass { reportAllChanges: true }
(leave onFCP/onLCP/onTTFB as-is) so all metric changes are sent to
enqueue(createMetricPayload(metric)). Ensure the metric type reflects
attribution in the callback signature.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 17, 2025

Note

Docstrings generation - SUCCESS
Generated docstrings for this pull request at #156

coderabbitai bot added a commit that referenced this pull request Oct 17, 2025
Docstrings generation was requested by @shayancoin.

* #95 (comment)

The following files were modified:

* `frontend/src/app/api/otel/webvital/route.ts`
* `frontend/src/app/providers.tsx`
* `frontend/src/app/web-vitals.ts`
@shayancoin shayancoin merged commit ae30634 into main Oct 17, 2025
0 of 5 checks passed
shayancoin pushed a commit that referenced this pull request Oct 18, 2025
Docstrings generation was requested by @shayancoin.

* #95 (comment)

The following files were modified:

* `frontend/src/app/api/otel/webvital/route.ts`
* `frontend/src/app/providers.tsx`
* `frontend/src/app/web-vitals.ts`

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant