Skip to content

Add client web vitals telemetry forwarding#83

Merged
shayancoin merged 3 commits intomainfrom
codex/implement-web-vitals-monitoring
Oct 16, 2025
Merged

Add client web vitals telemetry forwarding#83
shayancoin merged 3 commits intomainfrom
codex/implement-web-vitals-monitoring

Conversation

@shayancoin
Copy link
Owner

@shayancoin shayancoin commented Oct 16, 2025

Summary

  • add a client-side web vitals collector that batches navigation timings and measures to /api/otel/webvital
  • invoke the telemetry bootstrap on every page load and document the required OTEL collector configuration
  • create a Next.js API route that exports the incoming metrics as OTLP spans to the configured collector endpoint and add the necessary dependencies

Testing

  • npm run lint

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

Summary by CodeRabbit

  • Documentation

    • Added observability configuration documenting Web Vitals collection and required environment variables.
  • New Features

    • Automatic collection and batched sending of Web Vitals (CLS, LCP, INP, FCP, TTFB) with visibility/unload reporting.
    • Export of performance traces to OpenTelemetry collectors.
    • Init of Web Vitals tracking on app mount.
    • Added support for React Three Fiber for 3D rendering.
  • Chores

    • Next.js config updated to remove static export mode.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Note

Other AI code review bot(s) detected

CodeRabbit 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.

Walkthrough

Adds 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

Cohort / File(s) Summary
Documentation
frontend/README.md
Added "Observability Configuration" section documenting Web Vitals collection and required OTEL environment variables (OTEL_COLLECTOR_URL, OTEL_COLLECTOR_HOST/PORT, OTEL_COLLECTOR_PROTOCOL, OTEL_SERVICE_NAME).
Dependencies
frontend/package.json
Added OpenTelemetry packages (@opentelemetry/api, @opentelemetry/exporter-trace-otlp-http, @opentelemetry/resources, @opentelemetry/sdk-trace-base, @opentelemetry/semantic-conventions), web-vitals, and React Three Fiber packages (@react-three/fiber, @react-three/drei). zustand entry was reordered/updated.
Web Vitals Collection
frontend/src/app/web-vitals.ts
New module: registers web-vitals listeners (CLS, LCP, INP, FCP, TTFB), normalizes and batches metrics with navigation timings and custom measures, flushes via navigator.sendBeacon or fetch (keepalive), handles visibility/unload lifecycle and errors, and exports initWebVitals().
OpenTelemetry API Route
frontend/src/app/api/otel/webvital/route.ts
New POST route: accepts batched payload, conditionally initializes an OTLP tracer (config-resolved), creates a root span and per-metric/navigation/measure spans with attributes/events, attempts to flush to collector, and returns 202/502/400 based on result.
Provider Integration
frontend/src/app/providers.tsx
Added useEffect to call initWebVitals() once on mount to start collection.
Next.js Config
frontend/next.config.mjs
Removed output: 'export' and a production-only rewrite deletion path; rewrites are now always defined (no special-case removal for production).

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐇 I hop through metrics, soft and fleet,

CLS and LCP I gently meet.
Batched and beacons in the night,
Spans take off in telemetry flight.
Hooray — observability, feather-light!

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 repository’s required template: it lacks a “PR Type” section, uses “## Summary” instead of “# Short Description,” and the “## Testing” heading does not align with the “# Tests Added” template and only lists a lint command rather than actual tests. Update the description to use the template’s headings by adding a “PR Type” field, a concise “Short Description,” and a “Tests Added” section with details of any new or updated tests beyond linting.
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 and concisely summarizes the primary change by stating that client-side web vitals telemetry forwarding is being added, matching the main functionality implemented in this pull request. It is specific to the core feature without extraneous details or vague language.
✨ Finishing touches
  • 📝 Docstrings were successfully generated.
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/implement-web-vitals-monitoring

📜 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 bf55cb7 and 0e88c15.

📒 Files selected for processing (1)
  • frontend/next.config.mjs (0 hunks)
💤 Files with no reviewable changes (1)
  • frontend/next.config.mjs
🧰 Additional context used
🔍 Remote MCP DeepWiki, Sentry

Concise additional context for review

  • Sentry organization exists for this repo: paform — web URL https://paform.sentry.io (region: https://us.sentry.io). Use this org/region when configuring OTEL/trace correlation to Sentry or when querying Sentry APIs.

  • No projects are present in that Sentry organization (query returned no projects). Confirm where traces should be sent/associated or whether a project must be created/configured in Sentry before relying on Sentry ingestion.

  • No repository wiki/docs were found via DeepWiki (repo not indexed / "Repository not found"); suggests there is no existing project wiki to update with Observability/OTEL setup beyond the PR’s frontend/README changes.


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

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 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

Comment on lines +1 to +256
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 });
}

Choose a reason for hiding this comment

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

P0 Badge 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 👍 / 👎.

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: 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/test appear 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

📥 Commits

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

⛔ 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 (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 idempotent

useEffect placement is right; initWebVitals() is guarded. In dev HMR you may see re-inits, but the module-level initialized flag 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 solid

Beacon first, then fetch with keepalive: true. Good reliability pattern for unload.

Comment on lines 23 to 37
"@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"
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 | 🔴 Critical

🧩 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.

Comment on lines +32 to +39
## 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`).
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

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.

Suggested change
## 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.

Comment on lines +1 to +7
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';

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

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.

Comment on lines +83 to +94
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();

Copy link
Contributor

Choose a reason for hiding this comment

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

🛠️ 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.

Suggested change
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.

Comment on lines +195 to +205
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',
},
});

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 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.

Suggested change
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…
});
}

Comment on lines +230 to +236
if (metric.attribution) {
span.addEvent('attribution', metric.attribution);
}

if (startTime !== undefined) {
span.end(startTime);
} else {
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 | 🟡 Minor

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.

Suggested change
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).

Comment on lines +194 to +211
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);
}
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

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.

Suggested change
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.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 16, 2025

Note

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

coderabbitai bot added a commit that referenced this pull request Oct 16, 2025
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`
@shayancoin shayancoin merged commit c7cc168 into main Oct 16, 2025
0 of 5 checks passed
shayancoin pushed a commit that referenced this pull request Oct 18, 2025
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>
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