Skip to content

OpenTelemetry

Spans and metrics, from an OpenTelemetry SDK you own:

js
const api = require('@opentelemetry/api');
const { Server } = require('@alexify/wrpc');

const server = new Server({ router, telemetry: { api }, port: 8000, protocol: 'http' });

Pass the same option to a client and one trace covers both sides of the wire — the packet transports, the mapped REST leg (context rides real traceparent/tracestate headers there), the fastify adapter's delegated routes, and the cluster's node-to-node hop (context rides the backplane envelope).

OpenTelemetry is injected, never depended on

@alexify/wrpc has no dependencies and never will. The two constants it would otherwise need @opentelemetry/api for — SpanStatusCode.ERROR and the SpanKind values — are frozen by the specification and hardcoded. The @opentelemetry/* packages here are devDependencies, used only by the tests.

Two injection modes

js
telemetry: { api }                  // the @opentelemetry/api module
telemetry: { tracer, meter }        // instances you built

With { api }, wrpc derives its own tracer and meter so everything it emits carries the @alexify/wrpc instrumentation scope, and it can reach the propagator — which is what trace context needs.

With instances, either alone is fine: tracer-only gives spans, meter-only gives metrics. Propagation is off in this mode unless you also pass propagation (and context), because serializing W3C trace context without a propagator is not something wrpc will hand-roll.

Without a tracer and without a meter, telemetry is off and every recording path is a no-op.

Spans

Span names follow the OpenTelemetry rpc.* convention — $service/$method, which is the wire method string verbatim — because that is what Jaeger, Tempo and Datadog group RPC spans by.

SpanKind
{unit}/{method}SERVER — one RPC call
{unit}/{method} subscribeSERVER — a subscription's whole life
{unit}/{method} eventCONSUMER — one inbound event
{unit}/{method}CLIENT — the calling side

The call span brackets the whole invocation: the session wait, the access check, input validation and the timeout race. An argument error gets an error span and a duration sample exactly as a slow handler does.

Attributes

AttributeOn
rpc.system ('wrpc'), rpc.service, rpc.methodevery span
rpc.wrpc.status_code400, 403, 404, 408, 499, 500, 503
wrpc.statusok, error, cancelled, timeout
error.typethe error's constructor name
wrpc.packet.type, wrpc.packet.idevery span
wrpc.transportws, http, sse, event
wrpc.persistentwhether the connection stays open
wrpc.subscription.valuesvalues yielded
wrpc.subscription.terminalcomplete, error, unsubscribed
network.peer.addressthe peer's address — see below

Privacy

includeIdentity defaults to true. Setting it to false leaves network.peer.address — a remote IP — off every span:

js
telemetry: { api, includeIdentity: false }

A session token is never recorded, at any setting. A token is a credential, not an identity, and the two do not share a switch.

Metrics

MetricTypeUnit
rpc.server.durationHistogramms
rpc.client.durationHistogramms
wrpc.server.callsCounter{call}
wrpc.server.connectionsUpDownCounter{connection}
wrpc.server.subscriptionsUpDownCounter{subscription}
wrpc.server.subscription.valuesCounter{value}
wrpc.server.broadcastsCounter{event}
wrpc.server.broadcast.recipientsHistogram{client}
wrpc.server.stream.bytesCounterBy
wrpc.server.backpressureCounter{event}
wrpc.server.sessionsCounter{operation}
wrpc.server.sse.channelsUpDownCounter{channel}
wrpc.server.sse.eventsCounter{event}
wrpc.cluster.messagesCounter{message}
wrpc.cluster.requestsCounter{request}
wrpc.cluster.instancesUpDownCounter{instance}
wrpc.client.reconnectsCounter{attempt}
wrpc.client.refreshesCounter{run}
wrpc.client.connectionsUpDownCounter{connection}

wrpc.server.sse.events labels a closed kind set — open, reattach, replay, gap, expired — and the gap/expired series are real event loss, the signal replay sizing is tuned from. wrpc.client.reconnects counts every scheduled attempt (attempted) plus the terminal outcomes (recovered, exhausted), so its rate is the reconnect pressure and a storm that keeps recovering stays visible. Early HTTP/SSE refusals that happen before any client exists (CORS 403, 404, 405, capacity 429/503) are counted on wrpc.server.calls under the <unknown> target.

Metric attributes deliberately stay low-cardinality: the method, the status, the transport. Packet ids and peer addresses go on spans, never on a metric series.

Trace context

wrpc is a wire protocol, so the OpenTelemetry context manager alone cannot link the two sides — the caller is in another process. call, subscribe and event packets therefore carry two optional fields:

FieldCarries
tpW3C traceparent
tsW3C tracestate, omitted when empty

With { api } on both ends, a client span becomes the parent of the server span and one trace spans the network hop.

Both fields are optional in both directions. A peer that sends none leaves the receiver to start a root span; a peer that does not understand them ignores them like any other unknown field. The context is per packet, so each call in a batch keeps its own parent.

wrpc does not parse or serialize the W3C format — it hands the field to your propagator, so whichever one you configured globally (W3C, B3, Jaeger) is what runs. The full wire description is in the protocol reference.

Untrusted peers

trustRemoteContext defaults to true, as in gRPC and every HTTP instrumentation: an inbound traceparent becomes the server span's parent.

A hostile client can forge trace ids, inflating cardinality or poisoning the trace graph. The usual mitigation is at ingress, but a server facing untrusted clients can refuse them outright:

js
telemetry: { api, trustRemoteContext: false }

Every trace then starts on your side.

In a browser

The client injects trace context the same way. Without a ZoneContextManager or StackContextManager from the OpenTelemetry web SDK, context.active() returns the root — so the injected traceparent names the span wrpc just created. Still correct, just not linked to the surrounding page interaction. Register a web context manager if you want that link.

Failures

Telemetry never breaks a call. A broken exporter, a meter that throws, a span implementation missing half its methods, a tracer that dies before running its callback — each is contained, and each has a test. What propagates untouched is an error from your own handler; that is the one thing the wrapper must not swallow.

Released under the MIT License.