Skip to content

feat: add http.server.request.duration histogram metric#76

Merged
SaltyAom merged 1 commit into
elysiajs:mainfrom
turisanapo:feat/http-server-request-duration-metric
Apr 23, 2026
Merged

feat: add http.server.request.duration histogram metric#76
SaltyAom merged 1 commit into
elysiajs:mainfrom
turisanapo:feat/http-server-request-duration-metric

Conversation

@turisanapo

@turisanapo turisanapo commented Apr 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add the standard OTel http.server.request.duration histogram metric per the HTTP metrics semantic conventions
  • Record duration at the same three points where the root span ends (onAfterResponse, onError, abort) with the spec's required and conditionally required attributes
  • No new runtime dependencies — uses the metrics API already in @opentelemetry/api; when no MeterProvider is configured the meter is a no-op with zero overhead

Metric attributes

Attribute Requirement Level Source
http.request.method Required request.method
url.scheme Required Parsed from URL
http.response.status_code Conditionally Required Elysia context / error status
http.route Conditionally Required Elysia matched route
error.type Conditionally Required Status code string for >= 500

Histogram buckets

Uses the spec-recommended ExplicitBucketBoundaries: [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10]

Closes #75

Test plan

  • Successful request records metric with all standard attributes and duration in seconds
  • 500 error sets error.type attribute
  • Aborted request records metric via abort signal path
  • checkIfShouldTrace: () => false suppresses metrics
  • Multiple routes produce independent data points
  • All 45 pre-existing tests still pass (zero regressions)

Summary by CodeRabbit

  • New Features

    • Added HTTP request duration metrics (histogram) with attributes for method, status code, route, scheme and error classification.
    • Ensures durations are recorded reliably on normal completion, errors, and aborted requests.
  • Tests

    • Added end-to-end tests validating duration metrics, error tagging, abort handling, conditional tracing, and distinct route attributes.

@coderabbitai

coderabbitai Bot commented Apr 14, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 80dcee29-90ff-44e1-a77f-ea3f5b095363

📥 Commits

Reviewing files that changed from the base of the PR and between 880f42e and 3a0c958.

📒 Files selected for processing (2)
  • src/index.ts
  • test/metrics.test.ts
✅ Files skipped from review due to trivial changes (1)
  • test/metrics.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/index.ts

Walkthrough

OpenTelemetry histogram http.server.request.duration added to the Elysia plugin: request start time captured, duration computed in seconds at request end/abort/error, and recorded with HTTP attributes (http.request.method, http.route, http.response.status_code) and error.type for 5xx responses. Tests added validating five scenarios. Ugh, you're welcome ♡ (^ω^)

Changes

Cohort / File(s) Summary
Metrics Implementation
src/index.ts
Added an OpenTelemetry Meter and http.server.request.duration histogram (seconds, explicit buckets). Capture performance.now() at request start; compute and record duration once per request (guarded by durationRecorded) on completion, error, or abort. Adds attributes: http.request.method, http.route, http.response.status_code, url.scheme, and sets error.type for status >=500.
Metrics Test Suite
test/metrics.test.ts
New Bun test file with a TestMetricReader and per-test MeterProvider setup. Verifies: successful request metric and attributes, error sets error.type and 5xx status, aborted request still records duration, disabled tracing/metrics yields no datapoints, and distinct routes produce separate datapoints.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Plugin as Elysia Plugin
    participant Span as Root Span
    participant Meter as Histogram Meter
    participant Reader as MetricReader

    Client->>Plugin: HTTP request
    Plugin->>Plugin: capture startTime (performance.now())
    Plugin->>Span: create root span
    Span->>Span: handle request (route handler)
    alt completes (success or error)
        Span->>Plugin: invoke completion handlers (onAfterResponse / onError)
    else aborted
        Client-->>Plugin: abort signal
        Plugin->>Plugin: trigger abort handler
    end
    Plugin->>Plugin: if not recorded -> compute duration (s) & set attrs (method, route, status, scheme, error.type?)
    Plugin->>Meter: record histogram `http.server.request.duration`
    Meter->>Reader: export datapoint
    Reader->>Reader: collect/store datapoint
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

♡ New metrics bloom, latency confessed~ (¬‿¬)
Histograms sip seconds, answers quietly addressed~ (^▽^)
Errors wear 500 like a grumpy crown~ (¬_¬ )
Aborts still counted, you slacko, don't frown~ (^ω^)
Tiny hearts for telemetry, go brag about your crown~ (⌒‿⌒)

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely captures the main feature addition: implementing the http.server.request.duration histogram metric as the primary change.
Linked Issues check ✅ Passed The PR implementation fully satisfies all acceptance criteria from issue #75: histogram metric added, required attributes included, duration in seconds, span functionality unaffected, and comprehensive test coverage validates all requirements.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the http.server.request.duration metric: core histogram setup, per-request recording with proper attributes, and corresponding test coverage—no unrelated modifications detected.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/index.ts (1)

947-959: ⚠️ Potential issue | 🟠 Major

Remove the abort event listener when the request completes~

That abort handler closes over rootSpan, attributes, recordDuration, and the request context without ever removing itself. Even though the listener checks if rootSpan.ended and bails early, you're still holding onto those closure references needlessly. If a signal gets reused or hangs around (especially in non-standard scenarios), you'll leak one stale listener per request, which is sloppy~♡

Attach a corresponding removeEventListener call in your event.onStop handler or wherever the span cleanup happens (onAfterResponse, onError). Don't be lazy!

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

In `@src/index.ts` around lines 947 - 959, The abort listener added via
context.request.signal.addEventListener currently closes over rootSpan,
recordDuration and request context and is never removed; change the code to
assign the listener to a const (e.g. const onAbort = () => { ... }) and register
that instead, then call context.request.signal.removeEventListener('abort',
onAbort) from your span cleanup path (event.onStop or onAfterResponse/onError
where you already end rootSpan and call recordDuration) to release the closure
and avoid leaking per-request listeners; ensure you still perform the same
status/setDuration/end logic inside the onAbort handler before removing it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/index.ts`:
- Around line 497-515: The recordDuration function currently omits
server.address and server.port; modify the httpServerDuration.record call so it
conditionally includes 'server.address' and 'server.port' from the attributes
object only when they are already defined (e.g., attributes['server.address'] !=
null and attributes['server.port'] != null) to avoid recording undefined values
in early-abort cases; locate this change around the recordDuration function and
the httpServerDuration.record invocation where other attributes are spread and
add conditional inclusion for those two keys.

In `@test/metrics.test.ts`:
- Around line 38-46: Your test hooks only disable metrics but leave the global
tracer provider (started by opentelemetry() as a NodeSDK) around; update
beforeEach/afterEach to also reset/shutdown tracing: in beforeEach, ensure any
existing global tracer provider is cleared or replaced (match the pattern in
test/core.test.ts), and in afterEach await opentelemetry().shutdown() (or
shutdown the NodeSDK returned by opentelemetry()) in addition to awaiting
meterProvider.shutdown() and metrics.disable() so tracing state is cleaned
between tests.

---

Outside diff comments:
In `@src/index.ts`:
- Around line 947-959: The abort listener added via
context.request.signal.addEventListener currently closes over rootSpan,
recordDuration and request context and is never removed; change the code to
assign the listener to a const (e.g. const onAbort = () => { ... }) and register
that instead, then call context.request.signal.removeEventListener('abort',
onAbort) from your span cleanup path (event.onStop or onAfterResponse/onError
where you already end rootSpan and call recordDuration) to release the closure
and avoid leaking per-request listeners; ensure you still perform the same
status/setDuration/end logic inside the onAbort handler before removing it.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 461c9c6c-91dd-4a6b-bf70-7158fb44565c

📥 Commits

Reviewing files that changed from the base of the PR and between 84526ef and 880f42e.

📒 Files selected for processing (2)
  • src/index.ts
  • test/metrics.test.ts

Comment thread src/index.ts Outdated
Comment on lines +497 to +515
const recordDuration = () => {
if (durationRecorded) return
durationRecorded = true

const durationS =
(performance.now() - requestStartTime) / 1000
const statusCode =
attributes['http.response.status_code']

httpServerDuration.record(durationS, {
'http.request.method': attributes['http.request.method'] ?? method,
'url.scheme': attributes['url.scheme'],
'http.response.status_code': statusCode,
'http.route': attributes['http.route'],
...(typeof statusCode === 'number' &&
statusCode >= 500 && {
'error.type': String(statusCode)
})
})

@coderabbitai coderabbitai Bot Apr 14, 2026

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 | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the recordDuration() implementation around lines 497-515
sed -n '490,520p' src/index.ts

Repository: elysiajs/opentelemetry

Length of output: 1035


🏁 Script executed:

# Now check where server.address and server.port are populated (around lines 647-650)
sed -n '640,660p' src/index.ts

Repository: elysiajs/opentelemetry

Length of output: 602


🏁 Script executed:

# Get a broader view of the recordDuration function and when it's called
sed -n '480,550p' src/index.ts

Repository: elysiajs/opentelemetry

Length of output: 1896


🏁 Script executed:

# Check the overall structure to understand the flow - when is recordDuration called vs when are attributes set
rg 'recordDuration|server\.address|server\.port' -B 2 -A 2 src/index.ts | head -100

Repository: elysiajs/opentelemetry

Length of output: 701


🏁 Script executed:

# Get line numbers for context to see the execution flow
sed -n '470,660p' src/index.ts | cat -n | head -200

Repository: elysiajs/opentelemetry

Length of output: 6159


🏁 Script executed:

# Find where the abort listener is installed and when recordDuration is called
rg -n 'addEventListener|recordDuration|server\.port|server\.address' src/index.ts | head -30

Repository: elysiajs/opentelemetry

Length of output: 429


🏁 Script executed:

# Check the sequence: when are server attrs set vs when is abort listener attached
sed -n '490,700p' src/index.ts | cat -n

Repository: elysiajs/opentelemetry

Length of output: 6899


🏁 Script executed:

# Let me verify the execution order of callbacks to confirm the timing issue
sed -n '947,960p' src/index.ts

Repository: elysiajs/opentelemetry

Length of output: 422


🏁 Script executed:

# Check the semantic conventions for http.server.request.duration
web_search

Repository: elysiajs/opentelemetry

Length of output: 116


🏁 Script executed:

# Verify if server.address and server.port would be available by the time recordDuration is called
rg -n 'onTransform|onError|onAfterHandle|addEventListener' src/index.ts | head -20

Repository: elysiajs/opentelemetry

Length of output: 336


🌐 Web query:

OpenTelemetry HTTP server request duration metric semantic conventions recommended attributes

💡 Result:

The OpenTelemetry HTTP server request duration metric is http.server.request.duration, a required Histogram instrument with unit s measuring the duration of HTTP server requests. It should use explicit bucket boundaries [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10] seconds. When reported alongside an HTTP server span, the metric value should match the span duration. Recommended attributes (from official semantic conventions): Required: - http.request.method (string): HTTP request method (e.g., GET, POST, HEAD). Must be a known method or _OTHER. Conditionally Required: - url.scheme (string): URI scheme (e.g., http, https). - http.response.status_code (int): HTTP response status code, if received/sent. - error.type (string): Error class, if the request ended in error. Additional recommended attributes from conventions (often used with this metric): - server.address (string): Local server name/IP that received the request. - server.port (int): Local server port. - http.route (string): Matched route/path template (if supported by framework). - network.protocol.name (string) and network.protocol.version (string): e.g., http, 1.1. These are defined in the HTTP metrics semantic conventions (stable as of latest docs).

Citations:


🏁 Script executed:

# Let me understand the lifecycle better - when does onTransform execute relative to abort listener registration
sed -n '520,950p' src/index.ts | cat -n | grep -A 5 -B 5 'onTransform\|addEventListener'

Repository: elysiajs/opentelemetry

Length of output: 868


🏁 Script executed:

# Check line 940 to see the other recordDuration call
sed -n '935,945p' src/index.ts

Repository: elysiajs/opentelemetry

Length of output: 201


🏁 Script executed:

# Let me trace the actual execution flow more clearly - checking where each lifecycle hook/listener is registered
sed -n '520,950p' src/index.ts | cat -n | head -100

Repository: elysiajs/opentelemetry

Length of output: 2926


🏁 Script executed:

# Verify the exact positions in the file
git ls-files src/index.ts
wc -l src/index.ts

Repository: elysiajs/opentelemetry

Length of output: 95


🏁 Script executed:

# Let me trace the exact sequence to understand when server attributes would be available
# Check what's between the second onTransform registration and the abort listener registration

sed -n '607,947p' src/index.ts | tail -50

Repository: elysiajs/opentelemetry

Length of output: 1247


🏁 Script executed:

# Also check if there's any early exit/completion before the second onTransform
# Look for the actual handler structure
sed -n '470,550p' src/index.ts

Repository: elysiajs/opentelemetry

Length of output: 2195


🏁 Script executed:

# Let me verify the execution order more carefully by looking at the complete lifecycle
# Check if there are any conditions/early returns that would affect when server attributes are set
sed -n '607,700p' src/index.ts | cat -n

Repository: elysiajs/opentelemetry

Length of output: 3335


Include server attrs when available, but handle the early-abort edge case~ ✧(๑•́ ᗜ •̀๑) ✧

The metric recording is skipping server.address and server.port even though these are in the attributes object and recommended by OpenTelemetry semantic conventions. However, be careful: if the request aborts very early (before the second onTransform executes), these attributes won't be populated yet. The patch shown adds them unconditionally, which could record undefined values in early abort scenarios.

Better approach: either (1) add them unconditionally with a fallback, or (2) conditionally include them only if already available. The simple addition shown is a good start though~ ♡

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

In `@src/index.ts` around lines 497 - 515, The recordDuration function currently
omits server.address and server.port; modify the httpServerDuration.record call
so it conditionally includes 'server.address' and 'server.port' from the
attributes object only when they are already defined (e.g.,
attributes['server.address'] != null and attributes['server.port'] != null) to
avoid recording undefined values in early-abort cases; locate this change around
the recordDuration function and the httpServerDuration.record invocation where
other attributes are spread and add conditional inclusion for those two keys.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentionally omitted. The OTel spec marks server.address and server.port as Opt-In and explicitly warns they can be exploited to trigger cardinality limits since they're derived from headers. For a general-purpose plugin, the safer default is to exclude them. Users who need them can add them via a View on their MeterProvider.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@turisanapo Ahh, that makes total sense~ ♡ You're right, I missed that distinction in the spec — marking them as Opt-In with an explicit cardinality warning is exactly the kind of thing that makes a default-safe general-purpose plugin! (๑•̀ᗜ•́)و ✧ Excluding them by default and letting users opt in via a View is the correct call here. I'll remember this for future reviews~


✏️ Learnings added
Learnt from: turisanapo
Repo: elysiajs/opentelemetry PR: 76
File: src/index.ts:0-0
Timestamp: 2026-04-14T10:47:55.631Z
Learning: In the `elysiajs/opentelemetry` plugin (`src/index.ts`), `server.address` and `server.port` are intentionally excluded from the `http.server.request.duration` histogram metric attributes. The OpenTelemetry HTTP metrics semantic conventions mark these as **Opt-In** and warn they can be exploited to trigger cardinality explosions since they are derived from request headers. The safe default for a general-purpose plugin is to omit them; users who need them can add them via a `View` on their `MeterProvider`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread test/metrics.test.ts
@turisanapo turisanapo force-pushed the feat/http-server-request-duration-metric branch 4 times, most recently from 0f9763f to cdf5a2f Compare April 14, 2026 10:52
Record the standard OTel HTTP server duration metric alongside existing
traces, per the HTTP metrics semantic conventions. The histogram is
emitted at the same three points where the root span ends
(onAfterResponse, onError, abort) with the required and conditionally
required attributes: http.request.method, url.scheme,
http.response.status_code, http.route, and error.type.

No new runtime dependencies — uses the metrics API already included in
@opentelemetry/api. When no MeterProvider is configured the meter is a
no-op with zero overhead.

Closes elysiajs#75

Made-with: Cursor
@turisanapo turisanapo force-pushed the feat/http-server-request-duration-metric branch from cdf5a2f to 3a0c958 Compare April 14, 2026 13:21
@SaltyAom SaltyAom merged commit 45e9446 into elysiajs:main Apr 23, 2026
2 checks 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.

Add http.server.request.duration metric support

2 participants