Skip to content

feat: add OpenTelemetry Prometheus metrics#34

Merged
rsbh merged 5 commits into
mainfrom
feat_add_otel
Apr 14, 2026
Merged

feat: add OpenTelemetry Prometheus metrics#34
rsbh merged 5 commits into
mainfrom
feat_add_otel

Conversation

@rsbh
Copy link
Copy Markdown
Member

@rsbh rsbh commented Apr 14, 2026

Summary

  • Add toggleable telemetry config (telemetry.enabled, telemetry.serviceName) to chronicle.yaml
  • Add OTel MeterProvider with Prometheus exporter and /api/metrics endpoint
  • Instrument Nitro HTTP handlers (request count, duration histogram) via plugin
  • Instrument SSR render duration histogram
  • Exclude /api/metrics from self-instrumentation

Test plan

  • Enable telemetry.enabled: true in chronicle.yaml
  • Run chronicle dev and hit /api/metrics — verify Prometheus format output
  • Browse pages and verify http_server_request_total and http_server_request_duration_ms update
  • Verify http_server_ssr_render_duration_ms records for SSR pages
  • Verify /api/metrics route itself is not recorded in metrics
  • Verify disabled by default — no metrics endpoint when telemetry.enabled is absent

🤖 Generated with Claude Code

rsbh and others added 5 commits April 14, 2026 14:42
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 14, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Introduced observability capabilities with Prometheus metrics collection
    • Monitor application performance through HTTP request metrics and server-side rendering duration
    • New /api/metrics endpoint exposes metrics in Prometheus-compatible text format
    • Telemetry is opt-in and configurable with customizable service naming

Walkthrough

This PR adds OpenTelemetry-based telemetry and Prometheus metrics collection to Chronicle. It introduces dependencies, a metrics API endpoint, a telemetry initialization module, a Nitro plugin for request/response recording, SSR render timing, and configuration schema support.

Changes

Cohort / File(s) Summary
Dependencies & Configuration
packages/chronicle/package.json, packages/chronicle/src/types/config.ts
Added OpenTelemetry dependencies (@opentelemetry/api, @opentelemetry/exporter-prometheus, @opentelemetry/resources, @opentelemetry/sdk-metrics, @opentelemetry/semantic-conventions) and new optional telemetry configuration schema with enabled and serviceName fields.
Core Telemetry Infrastructure
packages/chronicle/src/server/telemetry.ts
New module initializing OpenTelemetry/Prometheus integration with initTelemetry(), managing meters and histograms for HTTP requests and SSR rendering, and exposing recordRequest(), recordSSRRender(), and getExporter() functions.
Request/Response Instrumentation
packages/chronicle/src/server/plugins/telemetry.ts
New Nitro plugin that records HTTP request/response metrics by capturing request start time and computing duration, excluding /api/metrics endpoint.
SSR Render Timing
packages/chronicle/src/server/entry-server.tsx
Added SSR render duration measurement and recording via recordSSRRender() call after stream creation.
Metrics API Endpoint
packages/chronicle/src/server/api/metrics.ts
New API handler returning Prometheus-formatted metrics as plain text, with 404 fallback if telemetry is disabled.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Server
    participant Nitro Plugin
    participant OTel SDK
    participant Prometheus

    Client->>Server: HTTP Request
    Nitro Plugin->>Nitro Plugin: Record requestStart = now()
    Server->>OTel SDK: recordRequest(method, path, status, duration)
    OTel SDK->>OTel SDK: Update http_server_request_total counter
    OTel SDK->>OTel SDK: Record http_server_request_duration_ms
    Server->>Client: HTTP Response

    Client->>Server: GET /api/metrics
    Server->>Prometheus: getMetricsRequestHandler()
    Prometheus-->>Server: Formatted metrics (text/plain)
    Server->>Client: Prometheus metrics response
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • rohilsurana
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'feat: add OpenTelemetry Prometheus metrics' accurately and concisely describes the main change: adding OpenTelemetry-based Prometheus metrics to the codebase.
Description check ✅ Passed The description clearly relates to the changeset, detailing the telemetry configuration, MeterProvider setup, instrumentation of HTTP handlers and SSR rendering, and a comprehensive test plan.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat_add_otel

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@rsbh rsbh requested a review from rohilsurana April 14, 2026 09:14
Copy link
Copy Markdown

@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: 1

🧹 Nitpick comments (3)
packages/chronicle/src/server/telemetry.ts (2)

13-16: Module-level metrics are uninitialized until initTelemetry is called.

These variables are declared but not initialized. While recordRequest and recordSSRRender use optional chaining to handle this safely, getExporter() will return undefined before initialization. The metrics endpoint handler already checks for this, so this is acceptable.

Consider adding explicit | undefined types for clarity:

let exporter: PrometheusExporter | undefined
let requestCounter: Counter | undefined
let requestDuration: Histogram | undefined
let ssrRenderDuration: Histogram | undefined
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/telemetry.ts` around lines 13 - 16, The
module-level metrics (exporter, requestCounter, requestDuration,
ssrRenderDuration) are declared but not initialized which makes their type
effectively possibly undefined; update the declarations for PrometheusExporter,
Counter, and Histogram to explicitly include | undefined (i.e. exporter:
PrometheusExporter | undefined, requestCounter: Counter | undefined,
requestDuration: Histogram | undefined, ssrRenderDuration: Histogram |
undefined) so callers like getExporter(), recordRequest(), and recordSSRRender()
reflect the potential undefined state and keep type-checking clear.

18-36: Guard against multiple initializations.

initTelemetry can be called multiple times, which would create duplicate MeterProvider instances and potentially cause metric conflicts or memory leaks. Consider adding an initialization guard.

♻️ Proposed fix with initialization guard
+let initialized = false
+
 export function initTelemetry(config: ChronicleConfig) {
+  if (initialized) return
+  initialized = true
+
   const resource = resourceFromAttributes({
     [ATTR_SERVICE_NAME]: config.telemetry?.serviceName ?? 'chronicle',
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/telemetry.ts` around lines 18 - 36,
initTelemetry currently recreates exporter, MeterProvider, and metrics on every
call; add an initialization guard at the start of initTelemetry to make it
idempotent (e.g., a module-level boolean like telemetryInitialized or check if
exporter/provider are already set) and return early if already initialized;
ensure you reference and set telemetryInitialized (or reuse existing
exporter/provider) and avoid reassigning requestCounter, requestDuration, and
ssrRenderDuration when the guard prevents reinitialization so duplicate
MeterProvider instances are not created.
packages/chronicle/src/server/entry-server.tsx (1)

61-101: Timing captures stream creation, not full render completion.

The duration measured here is the time to create the ReadableStream, not the time to fully render the HTML. The actual rendering continues as the stream is consumed by the client. This is a reasonable metric for initial response latency, but be aware it doesn't reflect total SSR work.

If you intend to measure full render time, you'd need to consume the stream before measuring. The current approach is valid for measuring time-to-first-byte latency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/entry-server.tsx` around lines 61 - 101, The
measured renderDuration uses performance.now() around renderToReadableStream
(renderStart, renderDuration) which only captures stream creation
(time-to-first-byte) not full SSR completion; if you need full render time,
consume the ReadableStream produced by renderToReadableStream (e.g., read the
stream to completion or create a Response and await .text()) before computing
renderDuration and calling recordSSRRender(pathname, status, renderDuration);
otherwise leave as-is for TTFB metrics and add a comment clarifying the current
behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/chronicle/src/server/api/metrics.ts`:
- Around line 11-18: The Promise that produces metricsString can hang because it
neither rejects on errors nor times out; update the Promise around
exporter.getMetricsRequestHandler to accept (resolve, reject), add a timeout
timer that rejects after a short period and is cleared on resolve/reject, wrap
the call to exporter.getMetricsRequestHandler in try/catch and call reject if it
throws synchronously, and update mockRes.end to resolve only when data is
received (and to reject if data is missing or invalid); ensure the timeout is
cleared in both the resolve and reject paths so metricsString never hangs
indefinitely.

---

Nitpick comments:
In `@packages/chronicle/src/server/entry-server.tsx`:
- Around line 61-101: The measured renderDuration uses performance.now() around
renderToReadableStream (renderStart, renderDuration) which only captures stream
creation (time-to-first-byte) not full SSR completion; if you need full render
time, consume the ReadableStream produced by renderToReadableStream (e.g., read
the stream to completion or create a Response and await .text()) before
computing renderDuration and calling recordSSRRender(pathname, status,
renderDuration); otherwise leave as-is for TTFB metrics and add a comment
clarifying the current behavior.

In `@packages/chronicle/src/server/telemetry.ts`:
- Around line 13-16: The module-level metrics (exporter, requestCounter,
requestDuration, ssrRenderDuration) are declared but not initialized which makes
their type effectively possibly undefined; update the declarations for
PrometheusExporter, Counter, and Histogram to explicitly include | undefined
(i.e. exporter: PrometheusExporter | undefined, requestCounter: Counter |
undefined, requestDuration: Histogram | undefined, ssrRenderDuration: Histogram
| undefined) so callers like getExporter(), recordRequest(), and
recordSSRRender() reflect the potential undefined state and keep type-checking
clear.
- Around line 18-36: initTelemetry currently recreates exporter, MeterProvider,
and metrics on every call; add an initialization guard at the start of
initTelemetry to make it idempotent (e.g., a module-level boolean like
telemetryInitialized or check if exporter/provider are already set) and return
early if already initialized; ensure you reference and set telemetryInitialized
(or reuse existing exporter/provider) and avoid reassigning requestCounter,
requestDuration, and ssrRenderDuration when the guard prevents reinitialization
so duplicate MeterProvider instances are not created.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 7f3c9910-8b36-4293-b529-5e266447e8e5

📥 Commits

Reviewing files that changed from the base of the PR and between 50401fc and 26b8734.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (6)
  • packages/chronicle/package.json
  • packages/chronicle/src/server/api/metrics.ts
  • packages/chronicle/src/server/entry-server.tsx
  • packages/chronicle/src/server/plugins/telemetry.ts
  • packages/chronicle/src/server/telemetry.ts
  • packages/chronicle/src/types/config.ts

Comment on lines +11 to +18
const metricsString = await new Promise<string>((resolve) => {
const mockRes = {
setHeader: () => mockRes,
end: (data: string) => resolve(data),
} as unknown as ServerResponse

exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Promise may hang indefinitely if the handler fails.

The promise has no rejection path or timeout. If getMetricsRequestHandler throws an error or fails to call end(), this request will hang forever. Consider adding error handling and a timeout.

🛡️ Proposed fix with timeout and error handling
-  const metricsString = await new Promise<string>((resolve) => {
+  const metricsString = await new Promise<string>((resolve, reject) => {
+    const timeout = setTimeout(() => reject(new Error('Metrics collection timed out')), 5000)
     const mockRes = {
       setHeader: () => mockRes,
-      end: (data: string) => resolve(data),
+      end: (data: string) => {
+        clearTimeout(timeout)
+        resolve(data)
+      },
     } as unknown as ServerResponse
 
-    exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
+    try {
+      exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
+    } catch (error) {
+      clearTimeout(timeout)
+      reject(error)
+    }
-  })
+  }).catch((error) => {
+    throw new Error(`Failed to collect metrics: ${error.message}`)
+  })
📝 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 metricsString = await new Promise<string>((resolve) => {
const mockRes = {
setHeader: () => mockRes,
end: (data: string) => resolve(data),
} as unknown as ServerResponse
exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
})
const metricsString = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Metrics collection timed out')), 5000)
const mockRes = {
setHeader: () => mockRes,
end: (data: string) => {
clearTimeout(timeout)
resolve(data)
},
} as unknown as ServerResponse
try {
exporter.getMetricsRequestHandler({} as unknown as IncomingMessage, mockRes)
} catch (error) {
clearTimeout(timeout)
reject(error)
}
}).catch((error) => {
throw new Error(`Failed to collect metrics: ${error.message}`)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/chronicle/src/server/api/metrics.ts` around lines 11 - 18, The
Promise that produces metricsString can hang because it neither rejects on
errors nor times out; update the Promise around
exporter.getMetricsRequestHandler to accept (resolve, reject), add a timeout
timer that rejects after a short period and is cleared on resolve/reject, wrap
the call to exporter.getMetricsRequestHandler in try/catch and call reject if it
throws synchronously, and update mockRes.end to resolve only when data is
received (and to reject if data is missing or invalid); ensure the timeout is
cleared in both the resolve and reject paths so metricsString never hangs
indefinitely.

@rsbh rsbh merged commit 752b303 into main Apr 14, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants