Add client web vitals telemetry forwarding#83
Conversation
|
Note Other AI code review bot(s) detectedCodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review. WalkthroughAdds frontend Web Vitals collection and batching (via a new web-vitals module and provider hook), a new API route that converts batched metrics into OpenTelemetry spans and forwards them to an OTLP collector, dependency updates, README observability docs, and a Next.js config change removing static export mode. Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant App as App/Providers
participant WV as Web Vitals Module
participant Queue as Batch Queue
participant API as /api/otel/webvital
participant OTEL as OTLP Collector
App->>WV: initWebVitals() on mount
WV->>WV: register metric listeners (CLS,LCP,INP,FCP,TTFB)
User->>WV: metrics emitted
WV->>Queue: enqueue sanitized metric
alt batch full or flush trigger
WV->>API: POST batched payload (metrics, navigation, measures)
end
API->>API: init tracer (if configured), create root span
loop per metric
API->>API: create metric span (+ events/attributes)
end
API->>OTEL: flush spans to collector
OTEL-->>API: flush response
API-->>WV: 202 / 502 / 400
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: ASSERTIVE Plan: Pro 📒 Files selected for processing (1)
💤 Files with no reviewable changes (1)
🧰 Additional context used🔍 Remote MCP DeepWiki, SentryConcise additional context for review
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import { context, trace } from '@opentelemetry/api'; | ||
| import { BasicTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; | ||
| import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; | ||
| import { Resource } from '@opentelemetry/resources'; | ||
| import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; | ||
|
|
||
| type MetricPayload = { | ||
| name: string; | ||
| value: number; | ||
| delta: number; | ||
| id: string; | ||
| rating: string; | ||
| navigationType?: string; | ||
| entries?: Array<{ | ||
| name: string; | ||
| entryType: string; | ||
| startTime: number; | ||
| duration: number; | ||
| }>; | ||
| attribution?: Record<string, unknown>; | ||
| page?: string; | ||
| timestamp?: number; | ||
| }; | ||
|
|
||
| type NavigationPayload = { | ||
| name: string; | ||
| type: string; | ||
| startTime: number; | ||
| duration: number; | ||
| }; | ||
|
|
||
| type MeasurePayload = { | ||
| name: string; | ||
| startTime: number; | ||
| duration: number; | ||
| }; | ||
|
|
||
| type WebVitalPayload = { | ||
| metrics?: MetricPayload[]; | ||
| navigation?: NavigationPayload[]; | ||
| measures?: MeasurePayload[]; | ||
| userAgent?: string; | ||
| timeOrigin?: number; | ||
| }; | ||
|
|
||
| type TracerBundle = { | ||
| provider: BasicTracerProvider; | ||
| tracer: ReturnType<BasicTracerProvider['getTracer']>; | ||
| }; | ||
|
|
||
| type GlobalWithTracer = typeof globalThis & { | ||
| __webVitalTracer?: TracerBundle | null; | ||
| }; | ||
|
|
||
| const getCollectorUrl = (): string | undefined => { | ||
| const explicit = process.env.OTEL_COLLECTOR_URL?.replace(/\/$/, ''); | ||
| if (explicit) { | ||
| return `${explicit}/v1/traces`; | ||
| } | ||
|
|
||
| const host = process.env.OTEL_COLLECTOR_HOST; | ||
| const port = process.env.OTEL_COLLECTOR_PORT; | ||
|
|
||
| if (host && port) { | ||
| const protocol = process.env.OTEL_COLLECTOR_PROTOCOL ?? 'http'; | ||
| return `${protocol}://${host}:${port}/v1/traces`; | ||
| } | ||
|
|
||
| return undefined; | ||
| }; | ||
|
|
||
| const initialiseTracer = (): TracerBundle | null => { | ||
| const collectorUrl = getCollectorUrl(); | ||
| if (!collectorUrl) { | ||
| return null; | ||
| } | ||
|
|
||
| const exporter = new OTLPTraceExporter({ | ||
| url: collectorUrl, | ||
| }); | ||
|
|
||
| const provider = new BasicTracerProvider({ | ||
| resource: new Resource({ | ||
| [SemanticResourceAttributes.SERVICE_NAME]: | ||
| process.env.OTEL_SERVICE_NAME ?? 'paform-frontend', | ||
| [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: | ||
| process.env.NODE_ENV ?? 'development', | ||
| }), | ||
| }); | ||
|
|
||
| provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); | ||
| provider.register(); | ||
|
|
||
| return { | ||
| provider, | ||
| tracer: provider.getTracer('web-vital-ingest'), | ||
| }; | ||
| }; | ||
|
|
||
| const getTracer = (): TracerBundle | null => { | ||
| const globalScope = globalThis as GlobalWithTracer; | ||
| if (globalScope.__webVitalTracer === undefined) { | ||
| globalScope.__webVitalTracer = initialiseTracer(); | ||
| } | ||
|
|
||
| return globalScope.__webVitalTracer ?? null; | ||
| }; | ||
|
|
||
| const toTimeInput = (timestamp?: number): number | undefined => | ||
| typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : undefined; | ||
|
|
||
| const attributeFromMetric = (metric: MetricPayload) => ({ | ||
| 'webvital.name': metric.name, | ||
| 'webvital.value': metric.value, | ||
| 'webvital.delta': metric.delta, | ||
| 'webvital.rating': metric.rating, | ||
| 'webvital.id': metric.id, | ||
| 'webvital.page': metric.page ?? 'unknown', | ||
| 'webvital.navigation_type': metric.navigationType ?? 'unknown', | ||
| }); | ||
|
|
||
| const appendMeasureSpans = ( | ||
| tracerBundle: TracerBundle, | ||
| measures: MeasurePayload[] = [], | ||
| spanContext = context.active(), | ||
| timeOrigin?: number, | ||
| ) => { | ||
| measures.forEach((measure) => { | ||
| const fallbackNow = Date.now(); | ||
| const start = | ||
| typeof timeOrigin === 'number' | ||
| ? timeOrigin + measure.startTime | ||
| : fallbackNow - measure.duration; | ||
| const span = tracerBundle.tracer.startSpan( | ||
| `measure.${measure.name}`, | ||
| { | ||
| startTime: start, | ||
| attributes: { | ||
| 'measure.start': measure.startTime, | ||
| 'measure.duration': measure.duration, | ||
| }, | ||
| }, | ||
| spanContext, | ||
| ); | ||
| span.end(start + measure.duration); | ||
| }); | ||
| }; | ||
|
|
||
| const appendNavigationSpans = ( | ||
| tracerBundle: TracerBundle, | ||
| navigation: NavigationPayload[] = [], | ||
| spanContext = context.active(), | ||
| timeOrigin?: number, | ||
| ) => { | ||
| navigation.forEach((nav) => { | ||
| const fallbackNow = Date.now(); | ||
| const start = | ||
| typeof timeOrigin === 'number' | ||
| ? timeOrigin + nav.startTime | ||
| : fallbackNow - nav.duration; | ||
| const span = tracerBundle.tracer.startSpan( | ||
| `navigation.${nav.type}`, | ||
| { | ||
| startTime: start, | ||
| attributes: { | ||
| 'navigation.name': nav.name, | ||
| 'navigation.duration': nav.duration, | ||
| }, | ||
| }, | ||
| spanContext, | ||
| ); | ||
| span.end(start + nav.duration); | ||
| }); | ||
| }; | ||
|
|
||
| export async function POST(req: NextRequest) { | ||
| let payload: WebVitalPayload; | ||
|
|
||
| try { | ||
| payload = (await req.json()) as WebVitalPayload; | ||
| } catch (error) { | ||
| return NextResponse.json( | ||
| { error: 'Invalid payload', details: error instanceof Error ? error.message : String(error) }, | ||
| { status: 400 }, | ||
| ); | ||
| } | ||
|
|
||
| const tracerBundle = getTracer(); | ||
|
|
||
| if (!tracerBundle) { | ||
| return NextResponse.json({ skipped: 'collector not configured' }, { status: 202 }); | ||
| } | ||
|
|
||
| const { metrics = [], navigation = [], measures = [], userAgent } = payload; | ||
|
|
||
| const rootSpan = tracerBundle.tracer.startSpan('webvital.batch', { | ||
| attributes: { | ||
| 'webvital.metric_count': metrics.length, | ||
| 'webvital.navigation_count': navigation.length, | ||
| 'webvital.measure_count': measures.length, | ||
| 'user.agent.original': userAgent ?? 'unknown', | ||
| }, | ||
| }); | ||
|
|
||
| const rootContext = trace.setSpan(context.active(), rootSpan); | ||
|
|
||
| metrics.forEach((metric) => { | ||
| const startTime = toTimeInput(metric.timestamp); | ||
| const span = tracerBundle.tracer.startSpan( | ||
| `webvital.${metric.name}`, | ||
| { | ||
| attributes: attributeFromMetric(metric), | ||
| ...(startTime !== undefined ? { startTime } : {}), | ||
| }, | ||
| rootContext, | ||
| ); | ||
|
|
||
| if (metric.entries && metric.entries.length > 0) { | ||
| metric.entries.forEach((entry, index) => { | ||
| span.addEvent(`entry.${index}`, { | ||
| 'entry.name': entry.name, | ||
| 'entry.type': entry.entryType, | ||
| 'entry.start': entry.startTime, | ||
| 'entry.duration': entry.duration, | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| if (metric.attribution) { | ||
| span.addEvent('attribution', metric.attribution); | ||
| } | ||
|
|
||
| if (startTime !== undefined) { | ||
| span.end(startTime); | ||
| } else { | ||
| span.end(); | ||
| } | ||
| }); | ||
|
|
||
| appendNavigationSpans(tracerBundle, navigation, rootContext, payload.timeOrigin); | ||
| appendMeasureSpans(tracerBundle, measures, rootContext, payload.timeOrigin); | ||
|
|
||
| rootSpan.end(); | ||
|
|
||
| try { | ||
| await tracerBundle.provider.forceFlush(); | ||
| } catch (error) { | ||
| return NextResponse.json( | ||
| { error: 'Failed to forward web vitals to collector', details: error instanceof Error ? error.message : String(error) }, | ||
| { status: 502 }, | ||
| ); | ||
| } | ||
|
|
||
| return NextResponse.json({ accepted: true }, { status: 202 }); | ||
| } |
There was a problem hiding this comment.
Add route handler despite static-export build
The new /api/otel/webvital route relies on a Node.js runtime, but the frontend is still configured with output: 'export' in next.config.mjs. Static export builds do not support API routes or route handlers, so npm run build will now fail (Next emits “API routes are not supported with output: 'export'”) or, if the build is forced, /api/otel/webvital will 404 in production. Either drop the static-export mode or move the telemetry forwarding to a backend that runs server code.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
frontend/package.json (1)
39-64: Remove duplicate devDependencies
gltfpack,meshoptimizer, and@playwright/testappear twice. Keep one entry each."devDependencies": { - "@playwright/test": "^1.56.0", + "@playwright/test": "^1.56.0", @@ - "gltfpack": "0.25.0", + "gltfpack": "0.25.0", @@ - "meshoptimizer": "0.25.0", + "meshoptimizer": "0.25.0", @@ - "vitest": "^1.6.0", - "@playwright/test": "^1.56.0", + "vitest": "^1.6.0", "ts-node": "^10.9.2" }Alternatively, remove the later duplicates entirely:
- "gltfpack": "0.25.0", - "meshoptimizer": "0.25.0", - "vitest": "^1.6.0", - "@playwright/test": "^1.56.0", + "vitest": "^1.6.0",
📜 Review details
Configuration used: CodeRabbit UI
Review profile: ASSERTIVE
Plan: Pro
⛔ Files ignored due to path filters (1)
frontend/package-lock.jsonis 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 (2)
frontend/src/app/web-vitals.ts (1)
initWebVitals(194-211)frontend/src/app/layout.tsx (1)
RootLayout(16-30)
🪛 LanguageTool
frontend/README.md
[grammar] ~32-~32: Use correct spacing
Context: ...entions. ## Observability Configuration Web Vitals are collected in the browser ...
(QB_NEW_EN_OTHER_ERROR_IDS_5)
[grammar] ~34-~34: Use correct spacing
Context: ...ent variables when running the frontend: - OTEL_COLLECTOR_URL – full URL to the collector (e.g. `http...
(QB_NEW_EN_OTHER_ERROR_IDS_5)
[grammar] ~36-~36: There might be a mistake here.
Context: ...TOR_URL– full URL to the collector (e.g.http://otel-collector:4318`). - or `OT...
(QB_NEW_EN_OTHER)
[grammar] ~36-~36: There might be a mistake here.
Context: ...tor (e.g. http://otel-collector:4318). - or OTEL_COLLECTOR_HOST and `OTEL_COLLE...
(QB_NEW_EN)
[grammar] ~37-~37: There might be a mistake here.
Context: ...:4318). - or OTEL_COLLECTOR_HOSTandOTEL_COLLECTOR_PORT– host and port that will be combined intoht...
(QB_NEW_EN_OTHER)
[grammar] ~37-~37: There might be a problem here.
Context: ...ed into http(s)://HOST:PORT/v1/traces. - OTEL_COLLECTOR_PROTOCOL – optional protocol override (http by ...
(QB_NEW_EN_MERGED_MATCH)
[grammar] ~38-~38: There might be a problem here.
Context: ...default) when using host/port variables. - OTEL_SERVICE_NAME – optional service name reported to the ...
(QB_NEW_EN_MERGED_MATCH)
[grammar] ~39-~39: There might be a mistake here.
Context: ...llector (defaults to paform-frontend).
(QB_NEW_EN)
🔍 Remote MCP DeepWiki, Linear, Sentry
Additional context for PR #83 “Add client web vitals telemetry forwarding”
-
No project wiki pages exist for “Observability” or OpenTelemetry in this repo, suggesting no prior documented guidance on telemetry setup.
-
A search of Linear issues for “web vitals” returned no results, indicating this telemetry feature hasn’t been formally requested or discussed in issue tracking.
-
The Sentry organization for this codebase is paform (https://paform.sentry.io, region: https://us.sentry.io), which may serve as a destination for aggregated performance/error data or for correlating traces.
🔇 Additional comments (2)
frontend/src/app/providers.tsx (1)
25-27: LGTM; init on mount is correct and idempotentuseEffect placement is right;
initWebVitals()is guarded. In dev HMR you may see re-inits, but the module-levelinitializedflag mitigates it.Please confirm StrictMode is enabled; behavior should remain single-init per page load.
frontend/src/app/web-vitals.ts (1)
63-81: Transport fallback looks solidBeacon first, then fetch with
keepalive: true. Good reliability pattern for unload.
frontend/package.json
Outdated
| "@opentelemetry/api": "^1.9.0", | ||
| "@opentelemetry/exporter-trace-otlp-http": "^0.54.2", | ||
| "@opentelemetry/resources": "^1.24.0", | ||
| "@opentelemetry/sdk-trace-base": "^1.24.0", | ||
| "@opentelemetry/semantic-conventions": "^1.24.0", | ||
| "@react-three/drei": "^9.120.5", | ||
| "@react-three/fiber": "^8.17.10", | ||
| "framer-motion": "^11.5.4", | ||
| "next": "^14.2.28", | ||
| "react": "^18", | ||
| "react-dom": "^18", | ||
| "react-icons": "^5.3.0", | ||
| "zustand": "^4.5.0", | ||
| "three": "^0.171.0", | ||
| "@react-three/fiber": "^8.17.10", | ||
| "@react-three/drei": "^9.120.5" | ||
| "web-vitals": "^4.2.4", | ||
| "zustand": "^4.5.0" |
There was a problem hiding this comment.
🧩 Analysis chain
Verify OpenTelemetry package version compatibility
Mixed versions (@opentelemetry/* 1.24.x with exporter-trace-otlp-http 0.54.x) are common but should match the official compat matrix.
🌐 Web query:
OpenTelemetry JS compatibility: Are @opentelemetry/api/sdk-trace-base v1.24.x compatible with @opentelemetry/exporter-trace-otlp-http v0.54.x? Provide the official matrix or release notes confirming.
💡 Result:
Short answer: No — they are not officially compatible. The OpenTelemetry JS compatibility matrix shows stable 1.24.x pairs with experimental 0.51.x, while experimental 0.54.x pairs with stable 1.27.x. [1][2]
Sources:
- OpenTelemetry JS compatibility matrix (README). [1]
- @opentelemetry/exporter-trace-otlp-http (package/docs showing experimental exporter). [2]
Align OpenTelemetry package versions in frontend/package.json
- Core @opentelemetry/* packages are at 1.24.x while exporter-trace-otlp-http is 0.54.x—upgrade all stable packages to ^1.27.0 to match v0.54.x (or downgrade the exporter to ^0.51.0 for compatibility).
- Remove duplicate devDependencies (gltfpack, meshoptimizer, @playwright/test) to prevent version drift.
🤖 Prompt for AI Agents
In frontend/package.json around lines 23 to 37, OpenTelemetry package versions
are inconsistent (core @opentelemetry/* at 1.24.x while exporter-trace-otlp-http
is 0.54.x) and there are duplicate devDependencies (gltfpack, meshoptimizer,
@playwright/test); either bump all stable @opentelemetry packages to a
consistent version such as ^1.27.0 (including
@opentelemetry/exporter-trace-otlp-http) or downgrade the exporter to ^0.51.0 to
match the core suite, and remove the duplicated devDependency entries so each
dependency appears only once in package.json.
| ## Observability Configuration | ||
|
|
||
| Web Vitals are collected in the browser and forwarded to an OpenTelemetry collector from the `/api/otel/webvital` route. Configure the collector target using the following environment variables when running the frontend: | ||
|
|
||
| - `OTEL_COLLECTOR_URL` – full URL to the collector (e.g. `http://otel-collector:4318`). | ||
| - or `OTEL_COLLECTOR_HOST` and `OTEL_COLLECTOR_PORT` – host and port that will be combined into `http(s)://HOST:PORT/v1/traces`. | ||
| - `OTEL_COLLECTOR_PROTOCOL` – optional protocol override (`http` by default) when using host/port variables. | ||
| - `OTEL_SERVICE_NAME` – optional service name reported to the collector (defaults to `paform-frontend`). |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Clarify collector URL semantics and fix bullet wording/spaces
- Say “base collector origin (no path)” for OTEL_COLLECTOR_URL, since the code appends /v1/traces.
- Reword the “or” bullet and fix spacing.
-## Observability Configuration
-
-Web Vitals are collected in the browser and forwarded to an OpenTelemetry collector from the `/api/otel/webvital` route. Configure the collector target using the following environment variables when running the frontend:
-
-- `OTEL_COLLECTOR_URL` – full URL to the collector (e.g. `http://otel-collector:4318`).
-- or `OTEL_COLLECTOR_HOST` and `OTEL_COLLECTOR_PORT` – host and port that will be combined into `http(s)://HOST:PORT/v1/traces`.
-- `OTEL_COLLECTOR_PROTOCOL` – optional protocol override (`http` by default) when using host/port variables.
-- `OTEL_SERVICE_NAME` – optional service name reported to the collector (defaults to `paform-frontend`).
+## Observability Configuration
+
+Web Vitals are collected in the browser and forwarded to an OpenTelemetry collector from the `/api/otel/webvital` route. Configure the collector target using the following environment variables when running the frontend:
+
+- `OTEL_COLLECTOR_URL` – base collector origin (no path), e.g., `http://otel-collector:4318`. The route appends `/v1/traces`.
+- Alternatively, set `OTEL_COLLECTOR_HOST` and `OTEL_COLLECTOR_PORT`. The route builds `http(s)://HOST:PORT/v1/traces`.
+- `OTEL_COLLECTOR_PROTOCOL` – optional protocol override (`http` by default) when using host/port variables.
+- `OTEL_SERVICE_NAME` – optional service name reported to the collector (defaults to `paform-frontend`).📝 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.
| ## Observability Configuration | |
| Web Vitals are collected in the browser and forwarded to an OpenTelemetry collector from the `/api/otel/webvital` route. Configure the collector target using the following environment variables when running the frontend: | |
| - `OTEL_COLLECTOR_URL` – full URL to the collector (e.g. `http://otel-collector:4318`). | |
| - or `OTEL_COLLECTOR_HOST` and `OTEL_COLLECTOR_PORT` – host and port that will be combined into `http(s)://HOST:PORT/v1/traces`. | |
| - `OTEL_COLLECTOR_PROTOCOL` – optional protocol override (`http` by default) when using host/port variables. | |
| - `OTEL_SERVICE_NAME` – optional service name reported to the collector (defaults to `paform-frontend`). | |
| ## Observability Configuration | |
| Web Vitals are collected in the browser and forwarded to an OpenTelemetry collector from the `/api/otel/webvital` route. Configure the collector target using the following environment variables when running the frontend: | |
| - `OTEL_COLLECTOR_URL` – base collector origin (no path), e.g., `http://otel-collector:4318`. The route appends `/v1/traces`. | |
| - Alternatively, set `OTEL_COLLECTOR_HOST` and `OTEL_COLLECTOR_PORT`. The route builds `http(s)://HOST:PORT/v1/traces`. | |
| - `OTEL_COLLECTOR_PROTOCOL` – optional protocol override (`http` by default) when using host/port variables. | |
| - `OTEL_SERVICE_NAME` – optional service name reported to the collector (defaults to `paform-frontend`). |
🧰 Tools
🪛 LanguageTool
[grammar] ~32-~32: Use correct spacing
Context: ...entions. ## Observability Configuration Web Vitals are collected in the browser ...
(QB_NEW_EN_OTHER_ERROR_IDS_5)
[grammar] ~34-~34: Use correct spacing
Context: ...ent variables when running the frontend: - OTEL_COLLECTOR_URL – full URL to the collector (e.g. `http...
(QB_NEW_EN_OTHER_ERROR_IDS_5)
[grammar] ~36-~36: There might be a mistake here.
Context: ...TOR_URL– full URL to the collector (e.g.http://otel-collector:4318`). - or `OT...
(QB_NEW_EN_OTHER)
[grammar] ~36-~36: There might be a mistake here.
Context: ...tor (e.g. http://otel-collector:4318). - or OTEL_COLLECTOR_HOST and `OTEL_COLLE...
(QB_NEW_EN)
[grammar] ~37-~37: There might be a mistake here.
Context: ...:4318). - or OTEL_COLLECTOR_HOSTandOTEL_COLLECTOR_PORT– host and port that will be combined intoht...
(QB_NEW_EN_OTHER)
[grammar] ~37-~37: There might be a problem here.
Context: ...ed into http(s)://HOST:PORT/v1/traces. - OTEL_COLLECTOR_PROTOCOL – optional protocol override (http by ...
(QB_NEW_EN_MERGED_MATCH)
[grammar] ~38-~38: There might be a problem here.
Context: ...default) when using host/port variables. - OTEL_SERVICE_NAME – optional service name reported to the ...
(QB_NEW_EN_MERGED_MATCH)
[grammar] ~39-~39: There might be a mistake here.
Context: ...llector (defaults to paform-frontend).
(QB_NEW_EN)
🤖 Prompt for AI Agents
In frontend/README.md around lines 32 to 39, clarify that OTEL_COLLECTOR_URL
should be the base collector origin (no path) because the code appends
/v1/traces, and reword the alternative host/port bullet for clarity and spacing;
change the first bullet to explicitly say “OTEL_COLLECTOR_URL – base collector
origin (no path), e.g. http://otel-collector:4318” and replace the “or” bullet
with a single clearer line like “OTEL_COLLECTOR_HOST and OTEL_COLLECTOR_PORT –
host and port that will be combined into http(s)://HOST:PORT/v1/traces” ensuring
proper spacing/formatting.
| import { NextRequest, NextResponse } from 'next/server'; | ||
| import { context, trace } from '@opentelemetry/api'; | ||
| import { BasicTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base'; | ||
| import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; | ||
| import { Resource } from '@opentelemetry/resources'; | ||
| import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; | ||
|
|
There was a problem hiding this comment.
Force Node.js runtime to avoid Edge bundling/runtime breakage
This route imports Node-only OpenTelemetry SDK modules. If the route ever runs on Edge, it will fail to bundle/run.
+export const runtime = 'nodejs';
+
import { NextRequest, NextResponse } from 'next/server';🤖 Prompt for AI Agents
frontend/src/app/api/otel/webvital/route.ts lines 1-7: this route imports
Node-only OpenTelemetry SDK modules and must be forced to run in Node to avoid
Edge bundling/runtime failures; add a top-level declaration to force Node
runtime (export const runtime = 'node') at the top of the file before imports or
alternatively move this route to a server-only location, ensuring the file is
only executed in the Node runtime so the Node-only modules can be imported
safely.
| const provider = new BasicTracerProvider({ | ||
| resource: new Resource({ | ||
| [SemanticResourceAttributes.SERVICE_NAME]: | ||
| process.env.OTEL_SERVICE_NAME ?? 'paform-frontend', | ||
| [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: | ||
| process.env.NODE_ENV ?? 'development', | ||
| }), | ||
| }); | ||
|
|
||
| provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); | ||
| provider.register(); | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Use BatchSpanProcessor to reduce export overhead and improve throughput
SimpleSpanProcessor exports on every span end (many HTTP calls per request). BatchSpanProcessor coalesces exports and still respects forceFlush().
-import { BasicTracerProvider, SimpleSpanProcessor } from '@opentelemetry/sdk-trace-base';
+import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
@@
- provider.addSpanProcessor(new SimpleSpanProcessor(exporter));
+ provider.addSpanProcessor(
+ new BatchSpanProcessor(exporter, {
+ maxExportBatchSize: 64,
+ scheduledDelayMillis: 1000,
+ exportTimeoutMillis: 10000,
+ }),
+ );📝 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.
| const provider = new BasicTracerProvider({ | |
| resource: new Resource({ | |
| [SemanticResourceAttributes.SERVICE_NAME]: | |
| process.env.OTEL_SERVICE_NAME ?? 'paform-frontend', | |
| [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: | |
| process.env.NODE_ENV ?? 'development', | |
| }), | |
| }); | |
| provider.addSpanProcessor(new SimpleSpanProcessor(exporter)); | |
| provider.register(); | |
| const provider = new BasicTracerProvider({ | |
| resource: new Resource({ | |
| [SemanticResourceAttributes.SERVICE_NAME]: | |
| process.env.OTEL_SERVICE_NAME ?? 'paform-frontend', | |
| [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: | |
| process.env.NODE_ENV ?? 'development', | |
| }), | |
| }); | |
| provider.addSpanProcessor( | |
| new BatchSpanProcessor(exporter, { | |
| maxExportBatchSize: 64, | |
| scheduledDelayMillis: 1000, | |
| exportTimeoutMillis: 10000, | |
| }), | |
| ); | |
| provider.register(); |
🤖 Prompt for AI Agents
In frontend/src/app/api/otel/webvital/route.ts around lines 83 to 94, the tracer
is using SimpleSpanProcessor which exports every span end; replace it with
BatchSpanProcessor to batch exports and reduce overhead. Import
BatchSpanProcessor from @opentelemetry/sdk-trace-base, instantiate it with the
exporter (and sensible options like maxQueueSize, scheduleDelayMillis, and
exportTimeoutMillis if desired), add it to the provider instead of
SimpleSpanProcessor, and keep provider.register(); also ensure any forceFlush/
shutdown calls still work with the BatchSpanProcessor.
| const { metrics = [], navigation = [], measures = [], userAgent } = payload; | ||
|
|
||
| const rootSpan = tracerBundle.tracer.startSpan('webvital.batch', { | ||
| attributes: { | ||
| 'webvital.metric_count': metrics.length, | ||
| 'webvital.navigation_count': navigation.length, | ||
| 'webvital.measure_count': measures.length, | ||
| 'user.agent.original': userAgent ?? 'unknown', | ||
| }, | ||
| }); | ||
|
|
There was a problem hiding this comment.
Bound input sizes to prevent accidental/abusive load (DoS risk)
Unbounded metrics, navigation, measures, and per-metric entries can create thousands of spans per request. Cap sizes and/or reject over-limit payloads.
- const { metrics = [], navigation = [], measures = [], userAgent } = payload;
+ const MAX_METRICS = 200;
+ const MAX_NAV = 10;
+ const MAX_MEASURES = 100;
+ const MAX_ENTRY_EVENTS = 10;
+
+ const userAgent = payload.userAgent;
+ const metrics = (payload.metrics ?? []).slice(0, MAX_METRICS);
+ const navigation = (payload.navigation ?? []).slice(0, MAX_NAV);
+ const measures = (payload.measures ?? []).slice(0, MAX_MEASURES);And limit per-metric entries below:
- if (metric.entries && metric.entries.length > 0) {
- metric.entries.forEach((entry, index) => {
+ if (metric.entries && metric.entries.length > 0) {
+ metric.entries.slice(0, MAX_ENTRY_EVENTS).forEach((entry, index) => {Optionally, return 413 for egregious sizes to signal clients.
📝 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.
| const { metrics = [], navigation = [], measures = [], userAgent } = payload; | |
| const rootSpan = tracerBundle.tracer.startSpan('webvital.batch', { | |
| attributes: { | |
| 'webvital.metric_count': metrics.length, | |
| 'webvital.navigation_count': navigation.length, | |
| 'webvital.measure_count': measures.length, | |
| 'user.agent.original': userAgent ?? 'unknown', | |
| }, | |
| }); | |
| // Bound input sizes to prevent accidental/abusive load | |
| const MAX_METRICS = 200; | |
| const MAX_NAV = 10; | |
| const MAX_MEASURES = 100; | |
| const MAX_ENTRY_EVENTS = 10; | |
| const userAgent = payload.userAgent; | |
| const metrics = (payload.metrics ?? []).slice(0, MAX_METRICS); | |
| const navigation = (payload.navigation ?? []).slice(0, MAX_NAV); | |
| const measures = (payload.measures ?? []).slice(0, MAX_MEASURES); | |
| const rootSpan = tracerBundle.tracer.startSpan('webvital.batch', { | |
| attributes: { | |
| 'webvital.metric_count': metrics.length, | |
| 'webvital.navigation_count': navigation.length, | |
| 'webvital.measure_count': measures.length, | |
| 'user.agent.original': userAgent ?? 'unknown', | |
| }, | |
| }); | |
| // … later, when iterating over each metric … | |
| if (metric.entries && metric.entries.length > 0) { | |
| metric.entries | |
| .slice(0, MAX_ENTRY_EVENTS) | |
| .forEach((entry, index) => { | |
| // existing entry processing… | |
| }); | |
| } |
| if (metric.attribution) { | ||
| span.addEvent('attribution', metric.attribution); | ||
| } | ||
|
|
||
| if (startTime !== undefined) { | ||
| span.end(startTime); | ||
| } else { |
There was a problem hiding this comment.
Ensure attribution event uses valid attribute types
OTel event attributes must be strings/numbers/booleans/arrays. Passing an object may drop data. Serialize or flatten.
- if (metric.attribution) {
- span.addEvent('attribution', metric.attribution);
- }
+ if (metric.attribution) {
+ try {
+ span.addEvent('attribution', { 'attribution.json': JSON.stringify(metric.attribution) });
+ } catch {
+ // swallow; non-critical
+ }
+ }📝 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.
| if (metric.attribution) { | |
| span.addEvent('attribution', metric.attribution); | |
| } | |
| if (startTime !== undefined) { | |
| span.end(startTime); | |
| } else { | |
| if (metric.attribution) { | |
| try { | |
| span.addEvent( | |
| 'attribution', | |
| { 'attribution.json': JSON.stringify(metric.attribution) } | |
| ); | |
| } catch { | |
| // swallow; non-critical | |
| } | |
| } | |
| if (startTime !== undefined) { | |
| span.end(startTime); | |
| } else { |
🤖 Prompt for AI Agents
In frontend/src/app/api/otel/webvital/route.ts around lines 230 to 236, the code
calls span.addEvent('attribution', metric.attribution) but metric.attribution
may be an object which OTel rejects; convert attribution to valid attribute
types before adding the event. Fix by either JSON-stringifying the attribution
(span.addEvent('attribution', { value: JSON.stringify(metric.attribution) })) or
flattening it into primitive attributes (e.g., for each key in
metric.attribution add attribute 'attribution.<key>' with string/number/boolean
value), ensuring arrays and primitives are kept as-is and non-serializable
values are handled safely (use try/catch and fallback to JSON.stringify or a
placeholder).
| export function initWebVitals() { | ||
| if (initialized || typeof window === 'undefined') { | ||
| return; | ||
| } | ||
|
|
||
| initialized = true; | ||
|
|
||
| onCLS(reportHandler); | ||
| onLCP(reportHandler); | ||
| onINP(reportHandler); | ||
| onFCP(reportHandler); | ||
| onTTFB(reportHandler); | ||
|
|
||
| window.addEventListener('visibilitychange', handleVisibilityChange); | ||
| window.addEventListener('pagehide', handlePageHide); | ||
| window.addEventListener('beforeunload', handlePageHide); | ||
| window.addEventListener('freeze', handlePageHide); | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Optional: support a build-time opt-out flag
Consider allowing builds/environments to disable client collection without code changes.
export function initWebVitals() {
- if (initialized || typeof window === 'undefined') {
+ if (
+ initialized ||
+ typeof window === 'undefined' ||
+ process.env.NEXT_PUBLIC_DISABLE_WEB_VITALS === 'true'
+ ) {
return;
}📝 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.
| export function initWebVitals() { | |
| if (initialized || typeof window === 'undefined') { | |
| return; | |
| } | |
| initialized = true; | |
| onCLS(reportHandler); | |
| onLCP(reportHandler); | |
| onINP(reportHandler); | |
| onFCP(reportHandler); | |
| onTTFB(reportHandler); | |
| window.addEventListener('visibilitychange', handleVisibilityChange); | |
| window.addEventListener('pagehide', handlePageHide); | |
| window.addEventListener('beforeunload', handlePageHide); | |
| window.addEventListener('freeze', handlePageHide); | |
| } | |
| export function initWebVitals() { | |
| if ( | |
| initialized || | |
| typeof window === 'undefined' || | |
| process.env.NEXT_PUBLIC_DISABLE_WEB_VITALS === 'true' | |
| ) { | |
| return; | |
| } | |
| initialized = true; | |
| onCLS(reportHandler); | |
| onLCP(reportHandler); | |
| onINP(reportHandler); | |
| onFCP(reportHandler); | |
| onTTFB(reportHandler); | |
| window.addEventListener('visibilitychange', handleVisibilityChange); | |
| window.addEventListener('pagehide', handlePageHide); | |
| window.addEventListener('beforeunload', handlePageHide); | |
| window.addEventListener('freeze', handlePageHide); | |
| } |
🤖 Prompt for AI Agents
frontend/src/app/web-vitals.ts around lines 194 to 211: add a build-time opt-out
check to initWebVitals so environments can disable client collection without
code changes; read a compile-time constant (e.g. process.env.DISABLE_WEB_VITALS
or import.meta.env.VITE_DISABLE_WEB_VITALS) and short-circuit the function when
that flag is truthy before setting initialized or attaching listeners, ensure
the flag is typed/available at build time (update typings or Vite/webpack
DefinePlugin config as needed) and include a small comment documenting the flag
usage.
|
Note Docstrings generation - SUCCESS |
Docstrings generation was requested by @shayancoin. * #83 (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`
Docstrings generation was requested by @shayancoin. * #83 (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>
Summary
/api/otel/webvitalTesting
https://chatgpt.com/codex/tasks/task_e_68f12ada24ac8330b25006a64f33efb7
Summary by CodeRabbit
Documentation
New Features
Chores