Skip to content

feat: set the remaining semconv server span attributes - #39

Open
bengl wants to merge 1 commit into
nodejs:mainfrom
bengl:bengl/server-span-attributes
Open

bengl wants to merge 1 commit into
nodejs:mainfrom
bengl:bengl/server-span-attributes

Conversation

@bengl

@bengl bengl commented Nov 3, 2026

Copy link
Copy Markdown
Member

What changed.

HTTP/1 and HTTP/2 server spans now set the address attributes the HTTP
semantic conventions ask for: server.port, client.address,
network.peer.address, network.peer.port, and user_agent.original.
server.address and server.port come from the first of the Forwarded
header's host parameter, X-Forwarded-Host, and the Host header or
:authority, each parsed for both the host and the port, including
bracketed IPv6. When the chosen value omits the port, the default comes
from the Forwarded proto parameter, X-Forwarded-Proto, or, for the
authority, the request scheme. client.address comes from the first
Forwarded for parameter or the first X-Forwarded-For entry, and
otherwise from the immediate peer. The peer attributes come from the
socket, which is stream.session.socket for HTTP/2.

The proxy headers are followed the way OpenTelemetry's own HTTP
instrumentation follows them, although a client can spoof them. The
package has no runtime dependencies, so Forwarded is parsed by a small
RFC 7239 parser in lib/forwarded.js. It matches the forwarded-parse
package the official instrumentation uses on a corpus of valid and
malformed values plus 200k fuzzed inputs.

Not taken.

X-Forwarded-Proto does not change url.scheme. The conventions prefer
the scheme of the original request when it is known, but the official
instrumentation does not do that, so this change matches the
instrumentation instead.

Where this differs from the official instrumentation. Node joins repeated headers with commas, and proxies append to X-Forwarded-Host and X-Forwarded-Proto, so their first entry is the original, the way the first X-Forwarded-For entry is. The official instrumentation passes both through whole, which would make server.address a list. The proto matches in any letter case, since URI schemes are case-insensitive. A port that is not a decimal number from 0 to 65535 is omitted rather than exported as NaN, Infinity, or a rounded number, and client spans now use the same rule. Empty X-Forwarded-Host and X-Forwarded-For entries are treated as absent. A multi-colon authority without IPv6 brackets, which
the HTTP grammar does not allow, goes through the existing
splitAuthority instead of being kept whole.

Tests.

test/test-forwarded.js table-tests the parser directly, covering
quoted values, IPv6, escapes, case handling, and malformed input.
test/test-http-server.js and test/test-http2.js cover the span
attributes with no proxy headers, with Forwarded, and with
X-Forwarded-For and X-Forwarded-Host, for both protocols. Every new
test was proven to fail by breaking the behavior it covers and
restoring the file.

Measurements. On a quiet machine, against a copy of main at 72ffbae, on Node 26.12.1:

main This PR Change
http request (traced), run 1 25.27k ops/s 24.25k ops/s -4.1%
http request (traced), run 2 25.45k ops/s 24.80k ops/s -2.6%
http request (untraced), run 1 30.09k ops/s 30.64k ops/s +1.8%
http request (untraced), run 2 29.91k ops/s 29.90k ops/s -0.1%
microtel's own CPU, 50k traced requests 125.2 ms 140.4 ms +12%

Each throughput row is the median of 8 interleaved node:bench runs. The untraced rows never load microtel, so they show the noise. The CPU row is the median of 8 CPU profiles. Recording up to five more attributes per server span costs about 300 ns of microtel's own time per request, and 1 to 1.7 µs per request end to end. Leaving the new keys out of the preallocated attribute set made no difference.

Raw samples
  • traced, run 1, main: 24.89k, 25.09k, 25.62k, 25.22k, 25.44k, 25.82k, 25.22k, 25.32k
  • traced, run 1, this PR: 23.53k, 24.22k, 24.27k, 24.51k, 23.95k, 26.39k, 24.20k, 24.44k
  • traced, run 2, main: 24.94k, 25.38k, 24.77k, 25.77k, 25.56k, 28.10k, 25.27k, 25.53k
  • traced, run 2, this PR: 26.56k, 24.40k, 27.54k, 24.97k, 24.35k, 23.95k, 24.64k, 29.05k
  • microtel's own CPU, main: 132.4, 135.8, 121.9, 125.1, 117.0, 125.2, 138.0, 123.9 ms
  • microtel's own CPU, this PR: 140.0, 155.5, 146.2, 140.1, 148.9, 140.7, 128.6, 129.7 ms

Previously, the only address attribute HTTP/1 and HTTP/2 server spans
set was server.address, from the Host header or the :authority
pseudo-header. Now they also set server.port, client.address,
network.peer.address, network.peer.port, and user_agent.original, per
the HTTP semantic conventions.

server.address and server.port come from the first of the Forwarded
header's host parameter, the first X-Forwarded-Host entry, and the Host
header or :authority, each parsed for both the host and the port,
including bracketed IPv6. When the chosen value omits the port, the
default comes from the Forwarded proto parameter, the first
X-Forwarded-Proto entry, or, for the authority, the request scheme, in
any letter case. client.address comes from the first Forwarded for
parameter or the first X-Forwarded-For entry, and otherwise from the
immediate peer. The network.peer attributes come from the socket.

The proxy headers are followed the way OpenTelemetry's own HTTP
instrumentation follows them, although a client can spoof them. The
package has no runtime dependencies, so the Forwarded header is parsed
by a small RFC 7239 parser in lib/forwarded.js.

A port that is not a decimal number from 0 to 65535 is now omitted on
client spans too, rather than exported as NaN, Infinity, or a rounded
number.

Signed-off-by: Bryan English <bryan@bryanenglish.com>
@bengl
bengl marked this pull request as ready for review November 3, 2026 19:50
@bengl
bengl requested a review from a team November 3, 2026 19:50
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.

1 participant