Conversation
|
Caution Review failedThe pull request is closed. WalkthroughAdds 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: ASSERTIVE Plan: Pro ⛔ Files ignored due to path filters (1)
📒 Files selected for processing (2)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
📜 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 (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 withperformance.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/traceswithContent-Type: application/json; no changes required.
| ## 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. |
There was a problem hiding this comment.
🧹 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/jsonon/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) |
There was a problem hiding this comment.
🧹 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.
| 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.
| 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 }) | ||
| } |
There was a problem hiding this comment.
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.
| 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.
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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.
| function registerWebVitals() { | ||
| const report = (metric: Metric & { rating?: string }) => { | ||
| enqueue(createMetricPayload(metric)) | ||
| } | ||
|
|
||
| onCLS(report) | ||
| onFCP(report) | ||
| onINP(report) | ||
| onLCP(report) | ||
| onTTFB(report) | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial
Use attribution/soft‑navs builds and report all changes for richer insights.
- Prefer
web-vitals/attribution(orweb-vitals/attribution/soft-navsfor SPAs) to guaranteemetric.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.
|
Note Docstrings generation - SUCCESS |
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`
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>
Summary
Testing
https://chatgpt.com/codex/tasks/task_e_68f12bf585e083309c9bc07f5caf8d57
Summary by CodeRabbit
New Features
Documentation