Observability

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

A real-time service fails differently from a request/response service. Nothing is slow in the usual sense: the sockets stay open, the health check answers, and yet messages arrive late, a deploy turns into a reconnection storm, or a fifth of the calls never get past ICE. The usual dashboards (request rate, error rate, request duration) say almost nothing about that, because a WebSocket is one request that lasts for hours. This page lists what to measure instead, shows how to measure it on each stack of this section, how to scrape the media servers, how to write alerts for it and test them with promtool, how to follow one chat message across servers with OpenTelemetry, and how to log so that the three signals can be joined.

Baseline, checked on 2026-10-24: Prometheus and promtool 3.15.1, the Prometheus Node.js client @prometheus-io/client 0.16.1 (the new name of prom-client, see Version notes), OpenTelemetry JavaScript API 1.10.0 with @opentelemetry/sdk-node 0.300.0 and @opentelemetry/sdk-trace 3.0.0, OpenTelemetry Collector Contrib 0.163.2, pino 10.4.0, ws 8.22.0 on Node.js 24, socket.io 4.8.4, python-socketio 5.17.0 with prometheus_client 0.26.0, ASP.NET Core 10 with OpenTelemetry .NET 1.20.1, Spring Boot 4.1.2 with Micrometer 1.17.2 and Reactor Netty 1.3.8, Swoole 6.2.3, mediasoup 3.31.0, coturn 4.18.0 and LiveKit 1.13.10. The examples use the Chatterbox wire protocol of Message Design and Serialization and the Huddle call scenario of Building a Videoconference App.

The pipeline

Three kinds of telemetry leave a service, and they travel on different roads. Metrics are numbers that Prometheus pulls from a private /metrics port, traces are spans that the application pushes over OTLP to a collector, and logs are lines on standard output that the platform ships. Keep the roads separate, and tie the signals together with two ids: a connection id that is in every log line, and a trace id that is in every log line written while a span is active.

flowchart LR subgraph Node["Chatterbox node"] APP["WebSocket server"] end subgraph Media["Media plane"] TURN["coturn"] SFU["LiveKit / mediasoup"] end BROWSER["Browser getStats"] -->|"POST summary"| APP APP -->|"/metrics, private port"| PROM["Prometheus"] TURN -->|"9641/metrics"| PROM SFU -->|"6789/metrics"| PROM APP -->|"OTLP spans"| COL["OpenTelemetry Collector"] APP -->|"JSON lines on stdout"| LOG["Log shipper"] PROM --> ALERT["Alertmanager and Grafana"] COL --> TRACES["Trace backend"] LOG --> LOGS["Log store"]

Metrics that matter

Choose the few numbers that answer "is it working, is it slow, is it growing, is it breaking" and make each of them cheap. The table lists the set that the Chatterbox example below exports; every row has a counterpart on the other stacks.

Question Metric (Chatterbox name) Type How to read it

How many users are connected?

chatterbox_ws_connections

gauge

Capacity and sudden drops. A cliff means a crash or a network event, a slow decline means clients are giving up.

How fast do clients come and go?

chatterbox_ws_opened_total

counter

rate() divided by the connection gauge is the churn or reconnect rate. Above a few percent per minute is a storm.

How much traffic?

chatterbox_ws_messages_total by direction and type

counter

Messages per second in and out, per envelope type. The out/in ratio is the average fan-out.

How late is a message?

chatterbox_fanout_seconds

histogram

Server-side time to hand a message to every recipient. Alert on the p99, not the average.

How big is the fan-out?

chatterbox_fanout_recipients

histogram

Explains latency: a p99 of 50 ms with 1000 recipients is a different story from the same value with 5.

Why did sockets close?

chatterbox_ws_closed_total by code

counter

1000 and 1001 are healthy; 1006, 4408 and 4503 are the abnormal ones; 4429 and 4413 are abuse or bugs.

Are clients misbehaving?

chatterbox_ws_errors_total by code

counter

rate_limited and bad_request spikes point at a client release or an attacker.

Does resume work?

chatterbox_ws_hello_total by result

counter

The share of resumed handshakes after a deploy shows whether session recovery is doing its job.

Is someone not reading?

chatterbox_ws_send_buffer_max_bytes

gauge

The largest unsent buffer. A slow consumer holds memory for everyone.

Do calls connect?

chatterbox_client_ice_total by result and path

counter

ICE failure ratio, and the share of calls that need a TURN relay.

Are calls good?

chatterbox_client_rtt_seconds, chatterbox_client_loss_ratio

histogram

Quality as the clients measure it, from getStats().

Is the process healthy?

nodejs_eventloop_lag_p99_seconds, process_open_fds, process_resident_memory_bytes

gauge

A blocked event loop delays every socket on the node.

Rules that keep the metrics useful:

  • Label values come from small fixed sets. Close codes, envelope types, error codes and ICE results are bounded; room names, user ids, connection ids and IP addresses are not. A label with unbounded values creates one time series per value and eventually takes Prometheus down. The example clamps every label through a lookup (typeLabel, codeLabel), because a hostile client chooses the strings it sends. Put per-room and per-user detail in logs and traces, where a unique value is normal.

  • Count events, derive rates. Export counters and gauges, and compute rates in PromQL. A server-side "messages per second" gauge is wrong as soon as two scrapes are not exactly one interval apart.

  • Histograms for latency, with buckets that fit the budget. The example uses buckets from 1 ms to 1 s. Averages hide the tail that users notice, and Prometheus can only compute quantiles from buckets, so choose them around the service level objective. See Data Model and Metric Types.

  • Measure the server-side hand-off honestly. chatterbox_fanout_seconds stops when the frames are handed to the sockets, not when the browsers receive them. For end-to-end latency use the round trip of the presence heartbeat, which the server acknowledges, and record it on the client or in the quality report.

  • Keep the metrics port private. Metrics reveal sizes, versions and error rates. Serve them on a separate listener that only Prometheus can reach, never on the public WebSocket port or through the public proxy.

Example: instrumenting the Chatterbox Node server

The first example is a minimal Chatterbox server on ws that implements hello, join, message and presence, and exports the table above. metrics.js holds the instruments:

// Chatterbox metrics: every label has a small, fixed set of values.
import client from '@prometheus-io/client';

export const registry = new client.Registry();
registry.setDefaultLabels({ service: 'chatterbox' });
client.collectDefaultMetrics({ register: registry });

const TYPES = new Set(['hello', 'join', 'leave', 'message', 'edit', 'react', 'typing', 'receipt', 'presence', 'history', 'ack', 'error']);
const CODES = new Set([1000, 1001, 1006, 1009, 4400, 4401, 4403, 4408, 4413, 4426, 4429, 4503]);

// Bound the label values so that a hostile client cannot create new series.
export const typeLabel = (t) => (TYPES.has(t) ? t : 'unknown');
export const codeLabel = (c) => (CODES.has(c) ? String(c) : 'other');

export const connections = new client.Gauge({
  name: 'chatterbox_ws_connections', help: 'Open WebSocket connections', registers: [registry],
});
export const opened = new client.Counter({
  name: 'chatterbox_ws_opened_total', help: 'WebSocket connections accepted', registers: [registry],
});
export const closed = new client.Counter({
  name: 'chatterbox_ws_closed_total', help: 'WebSocket connections closed, by close code',
  labelNames: ['code'], registers: [registry],
});
export const messages = new client.Counter({
  name: 'chatterbox_ws_messages_total', help: 'Envelopes by direction and type',
  labelNames: ['direction', 'type'], registers: [registry],
});
// ... the instruments for errors, resumes, fan-out size, ICE and client quality follow the same pattern
export const fanout = new client.Histogram({
  name: 'chatterbox_fanout_seconds',
  help: 'Time from receiving a message to handing the frame to every recipient socket',
  buckets: [0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1],
  registers: [registry],
});

server.js records the events where they happen: the connection lifecycle, each envelope, the fan-out, the close code and the client quality reports. The listener on METRICS_PORT is separate from the WebSocket listener:

wss.on('connection', (ws) => {
  ws.id = randomUUID();
  sockets.add(ws);
  m.connections.inc();
  m.opened.inc();
  const clog = log.child({ conn: ws.id });
  clog.info({ event: 'ws_open' }, 'connection opened');

  ws.on('message', (data) => {
    const t0 = process.hrtime.bigint();
    let env;
    try { env = JSON.parse(data.toString('utf8')); } catch { return fail(ws, null, 'bad_request'); }
    const type = m.typeLabel(env?.type);
    m.messages.inc({ direction: 'in', type });
    withSpan(`chatterbox ${type}`, { 'messaging.system': 'chatterbox', 'messaging.operation.type': 'process', 'messaging.operation.name': type, 'messaging.destination.name': env.room ?? 'none' }, (span) => {
// ...
      } else if (type === 'message') {
        const set = rooms.get(env.room) ?? new Set();
        let n = 0;
        for (const peer of set) { send(peer, { type: 'message', room: env.room, payload: env.payload }); n++; }
        m.recipients.observe(n);
        m.fanout.observe(Number(process.hrtime.bigint() - t0) / 1e9);
        send(ws, { type: 'ack', room: env.room, payload: { ref: env.id, ok: true } });
        span.setAttribute('chatterbox.recipients', n);
// ...
  ws.on('close', (code) => {
    sockets.delete(ws);
    for (const set of rooms.values()) set.delete(ws);
    m.connections.dec();
    m.closed.inc({ code: m.codeLabel(code) });
    clog.info({ event: 'ws_close', code }, 'connection closed');
  });
// ...
function send(ws, partial) {
  if (ws.readyState !== WebSocket.OPEN) return;
  const env = { v: 1, id: `s_${randomUUID()}`, ts: Date.now(), ...partial };
  m.messages.inc({ direction: 'out', type: m.typeLabel(env.type) });
  ws.send(JSON.stringify(env));
}
// ...
// The metrics listener is separate and bound to loopback: never publish it through the public proxy.
http.createServer(async (req, res) => {
  if (req.url !== '/metrics') return res.writeHead(404).end();
  res.writeHead(200, { 'Content-Type': m.registry.contentType });
  res.end(await m.registry.metrics());
}).listen(METRICS_PORT, process.env.METRICS_HOST ?? '127.0.0.1');

A scrape of the running server after a test client opened three sockets, sent twenty messages and closed the sockets with 1000, 4408 and an abrupt termination looks like this (excerpt):

chatterbox_ws_connections{service="chatterbox"} 0
chatterbox_ws_opened_total{service="chatterbox"} 3
chatterbox_ws_closed_total{code="1006",service="chatterbox"} 1
chatterbox_ws_closed_total{code="1000",service="chatterbox"} 1
chatterbox_ws_closed_total{code="4408",service="chatterbox"} 1
chatterbox_ws_messages_total{direction="in",type="message",service="chatterbox"} 20
chatterbox_ws_messages_total{direction="out",type="message",service="chatterbox"} 60
chatterbox_ws_errors_total{code="bad_request",service="chatterbox"} 2
chatterbox_ws_hello_total{result="resumed",service="chatterbox"} 1
chatterbox_fanout_recipients_sum{service="chatterbox"} 60
chatterbox_fanout_recipients_count{service="chatterbox"} 20
chatterbox_client_ice_total{result="failed",path="direct",service="chatterbox"} 1

The 60 outbound messages for 20 inbound ones are the fan-out: three sockets were in the room. promtool check metrics reads an exposition from standard input and lints the names. Pointed at this endpoint it reports nothing for the chatterbox_ metrics; it only complains about three default nodejs_ gauges whose names end in _total:

curl -s http://chatterbox:18602/metrics | promtool check metrics

The browser side of call quality

Server-side metrics cannot see ICE or media quality; only the browser can. The Huddle client summarises the getStats() report into five numbers and posts them with sendBeacon, so the report survives a closing tab. The function reduces the report as Stats, Quality and Bandwidth describes, and keeps the previous totals so that loss is a ratio over the interval rather than since the start of the call:

// Browser side: reduce an RTCStatsReport to the five numbers the server aggregates.
export function summarise(report, previous = { lost: 0, received: 0 }) {
  let pair, local, lost = 0, received = 0;
  for (const s of report.values()) {
    if (s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded') pair = s;
    if (s.type === 'inbound-rtp' && s.kind === 'video') { lost += s.packetsLost ?? 0; received += s.packetsReceived ?? 0; }
  }
  if (pair) local = report.get(pair.localCandidateId);
  const dLost = lost - previous.lost, dRecv = received - previous.received;
  return {
    totals: { lost, received },
    body: {
      iceState: pair ? 'connected' : 'failed',
      candidateType: local?.candidateType ?? 'unknown',   // 'relay' means the call goes through TURN
      rttMs: pair ? pair.currentRoundTripTime * 1000 : undefined,
      lossRatio: dLost + dRecv > 0 ? dLost / (dLost + dRecv) : 0,
    },
  };
}

Feeding summarise a report with a nominated relay pair, a 120 ms round trip and 5 lost packets out of 500 returned iceState connected, candidateType relay, rttMs 120 and lossRatio 0.01; an empty report returned iceState failed, which is how a call that never connected is counted. The ingestQuality handler in server.js accepts at most 2 KiB, folds the candidate type into relay or direct, clamps the loss ratio and never stores the sender. A public endpoint like this is an injection point for fake statistics, so treat the resulting series as a trend, require the authenticated session in production, and rate limit it as described in Security.

Per-stack instrumentation

Every stack of this section exposes the same few facts, in a different place. The table says where to look; the examples that follow were run against the versions in the baseline.

Stack Built in Add yourself

Node.js ws and uWebSockets.js

Nothing: wss.clients.size and ws.bufferedAmount are the raw numbers

Counters and histograms as in the example above

Socket.IO (page)

io.engine.clientsCount, io.of('/').sockets.size, the connection_error engine event, the disconnect reason; the admin UI (@socket.io/admin-ui 0.5.1) for a live view

A Prometheus registry (next section); the admin UI is a debugging tool, not monitoring

SignalR (page)

Microsoft.AspNetCore.Http.Connections meter (signalr.server.active_connections, signalr.server.connection.duration), Kestrel meters, the Microsoft.AspNetCore.SignalR.Server and .Client ActivitySources

A Meter for message counts and fan-out time

Spring WebFlux (page)

Micrometer with Actuator, Reactor Netty server metrics when enabled

Gauges and counters in the WebSocketHandler

python-socketio (page)

Nothing

prometheus_client instruments in the event handlers

Django Channels (page)

Nothing

An ASGI middleware that wraps the WebSocket scope

Laravel Reverb (page)

Laravel Pulse recorders and cards; the ChannelCreated, ChannelRemoved, ConnectionPruned, MessageReceived and MessageSent events

A listener that counts the events into your metrics system

Swoole (page)

$server->stats()

A Prometheus text endpoint on a second listener

XMPP servers (page)

Prosody: mod_http_openmetrics; ejabberd: the mod_prometheus module of ejabberd-contrib

Scrape job and alerts

coturn, LiveKit

Built-in Prometheus endpoints

Scrape job and alerts

mediasoup

getStats() on producers, consumers and transports; worker.getResourceUsage()

An exporter, as shown below

Socket.IO

Socket.IO has no Prometheus endpoint of its own. The server object already knows the numbers, so a registry with collect() callbacks reads them at scrape time, and the disconnect reason (a fixed set of strings) becomes a label:

const connected = new client.Gauge({ name: 'chatterbox_sio_connected_sockets', help: 'Connected Socket.IO sockets in the main namespace', registers: [registry],
  collect() { this.set(io.of('/').sockets.size); } });
const engineClients = new client.Gauge({ name: 'chatterbox_sio_engine_clients', help: 'Engine.IO connections (HTTP long-polling and WebSocket)', registers: [registry],
  collect() { this.set(io.engine.clientsCount); } });
const disconnects = new client.Counter({ name: 'chatterbox_sio_disconnect_total', help: 'Disconnections by Socket.IO reason', labelNames: ['reason'], registers: [registry] });
const events = new client.Counter({ name: 'chatterbox_sio_events_total', help: 'Events received', labelNames: ['event'], registers: [registry] });
const engineErrors = new client.Counter({ name: 'chatterbox_sio_connection_errors_total', help: 'Failed handshakes by Engine.IO error code', labelNames: ['code'], registers: [registry] });
const upgrades = new client.Counter({ name: 'chatterbox_sio_transport_total', help: 'Sockets by final transport', labelNames: ['transport'], registers: [registry] });

const REASONS = new Set(['server namespace disconnect', 'client namespace disconnect', 'server shutting down', 'ping timeout', 'transport close', 'transport error', 'parse error', 'forced close', 'forced server close', 'client error']);
const EVENTS = new Set(['message', 'join', 'typing']);
io.engine.on('connection_error', (err) => engineErrors.inc({ code: String(err.code ?? 'unknown') }));

io.on('connection', (socket) => {
  socket.onAny((event) => events.inc({ event: EVENTS.has(event) ? event : 'other' }));
  socket.conn.on('upgrade', (transport) => upgrades.inc({ transport: transport.name }));
  socket.on('message', (text, ack) => { io.emit('message', text); ack?.({ ok: true }); });
  socket.on('disconnect', (reason) => disconnects.inc({ reason: REASONS.has(reason) ? reason : 'other' }));
});

With one WebSocket client and one long-polling client connected, the endpoint showed two sockets and two Engine.IO connections, one message event and one event counted as other, and after the clients left it showed one client namespace disconnect and one transport close. The reason values are documented on Socket.IO; ping timeout and transport close are the ones to watch, because they mean the client vanished instead of leaving. Behind the Redis adapter every node reports only its own sockets, so sum the gauge across nodes in PromQL.

SignalR

ASP.NET Core 10 publishes SignalR connection metrics through System.Diagnostics.Metrics, and since .NET 9 the hub server and the .NET client publish traces through two ActivitySource objects. The OpenTelemetry SDK collects them as soon as the names are registered. A small project that hosts a hub and calls it from a .NET client in the same process printed these instruments:

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("chatterbox-signalr"))
    .WithMetrics(m => m
        .AddMeter("Microsoft.AspNetCore.Http.Connections")   // signalr.server.*
        .AddMeter("Microsoft.AspNetCore.Server.Kestrel")     // kestrel.active_connections, kestrel.upgraded_connections
        .AddMeter("Chatterbox")                              // our own instruments
        .AddConsoleExporter((e, r) => r.PeriodicExportingMetricReaderOptions.ExportIntervalMilliseconds = 1000))
    .WithTracing(t => t
        .AddSource("Microsoft.AspNetCore.SignalR.Server")    // one span per hub method call
        .AddSource("Microsoft.AspNetCore.SignalR.Client")
        .AddConsoleExporter());
// ...
public class ChatHub : Hub
{
    private static readonly Meter Meter = new("Chatterbox");
    private static readonly Counter<long> Messages = Meter.CreateCounter<long>("chatterbox.messages", "{message}", "Envelopes received by type");
    private static readonly Histogram<double> Fanout = Meter.CreateHistogram<double>("chatterbox.fanout.duration", "s", "Time to hand a message to every connected client");

    public async Task Send(string room, string text)
    {
        var started = System.Diagnostics.Stopwatch.GetTimestamp();
        Messages.Add(1, new KeyValuePair<string, object?>("type", "message"));
        await Clients.All.SendAsync("message", room, text);
        Fanout.Record(System.Diagnostics.Stopwatch.GetElapsedTime(started).TotalSeconds);
    }
}

The run reported signalr.server.active_connections with the attribute signalr.transport set to web_sockets, signalr.server.connection.duration with signalr.connection.status normal_closure, the Kestrel instruments kestrel.active_connections and kestrel.upgraded_connections, and the custom chatterbox.messages and chatterbox.fanout.duration. The trace output showed a client span hub/Send and, in the same trace, a server span ChatHub/Send: the .NET client propagates the trace context, while other clients such as the JavaScript client do not, so a browser call starts a new trace on the server. There is no built-in message counter or close-code metric, which is why the hub counts its own. In production replace the console exporter with AddOtlpExporter(); the Prometheus exporter package for ASP.NET Core (OpenTelemetry.Exporter.Prometheus.AspNetCore) was still published only as beta versions on 2026-10-24, so the usual route is OTLP into a collector that exposes Prometheus.

Spring WebFlux

Spring Boot 4 with Actuator and the Micrometer Prometheus registry exposes /actuator/prometheus. Move it to a management port, switch on the Reactor Netty server metrics with a NettyServerCustomizer (they are off by default), and register your own meters in the handler. The Maven dependencies are spring-boot-starter-webflux, spring-boot-starter-actuator and io.micrometer:micrometer-registry-prometheus. These properties move the Actuator to its own port and expose only the health and Prometheus endpoints:

server.port=18681
management.server.port=18682
management.endpoints.web.exposure.include=health,prometheus
management.metrics.tags.service=chatterbox

The close code comes from session.closeStatus(), which also reports the abnormal 1006 when the client disappears:

  // Reactor Netty publishes its server metrics to Micrometer only when asked to.
  @Bean
  NettyServerCustomizer nettyMetrics() {
    return server -> server.metrics(true, uri -> uri.startsWith("/ws") ? "/ws" : "other");
  }
  // ...
  @Component
  static class ChatHandler implements WebSocketHandler {
    private final AtomicInteger open = new AtomicInteger();
    private final Counter received;
    private final MeterRegistry registry;

    ChatHandler(MeterRegistry registry) {
      this.registry = registry;
      Gauge.builder("chatterbox.ws.connections", open, AtomicInteger::get)
          .description("Open WebSocket sessions").register(registry);
      this.received = Counter.builder("chatterbox.ws.messages").tag("direction", "in").register(registry);
    }

    @Override
    public Mono<Void> handle(WebSocketSession session) {
      open.incrementAndGet();
      Mono<Void> echo = session.send(session.receive()
          .doOnNext(m -> received.increment())
          .map(m -> session.textMessage(m.getPayloadAsText())));
      Mono<Void> closeCode = session.closeStatus()
          .doOnNext(status -> Counter.builder("chatterbox.ws.closed")
              .tag("code", String.valueOf(status.getCode())).register(registry).increment())
          .then();
      return echo.and(closeCode).doFinally(signal -> open.decrementAndGet());
    }
  }

A Node.js client opened a socket, sent a frame, and closed with 4408; a second socket was terminated without a close frame. The Prometheus endpoint then showed chatterbox_ws_closed_total with code 4408 once and code 1006 once, chatterbox_ws_connections back at 0, reactor_netty_http_server_connections while a socket was open, and an http_server_requests_seconds_count row with uri /ws for the upgrade request only. Note that http.server.requests records the handshake, not the life of the socket, so it is useless for connection duration. For traces, Spring Boot 4 provides spring-boot-starter-opentelemetry; see the Spring Boot tracing documentation.

python-socketio

prometheus_client supplies counters, gauges and histograms and a built-in HTTP server for the metrics port. The disconnect handler receives the reason as its second argument in python-socketio 5.17:

connections = Gauge("chatterbox_sio_connections", "Connected Socket.IO sessions")
messages = Counter("chatterbox_sio_messages_total", "Events received", ["event"])
disconnects = Counter("chatterbox_sio_disconnects_total", "Disconnections by reason", ["reason"])

sio = socketio.AsyncServer(async_mode="asgi", cors_allowed_origins=["https://chat.example.test"])
app = socketio.ASGIApp(sio)


@sio.event
async def connect(sid, environ, auth):
    connections.inc()


@sio.event
async def disconnect(sid, reason):
    connections.dec()
    disconnects.labels(reason=str(reason)).inc()


@sio.event
async def message(sid, data):
    messages.labels(event="message").inc()
    await sio.emit("message", data, skip_sid=sid)
    return {"ok": True}
# ...
async def main():
    start_http_server(18661, addr="127.0.0.1")  # metrics: a separate listener, loopback only
    server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=18662, log_level="warning"))
    await server.serve()

Two clients connected, one called message with an acknowledgement and disconnected; the scrape showed chatterbox_sio_connections 1, one message event and chatterbox_sio_disconnects_total with reason client disconnect. The reason strings differ from the JavaScript server’s, so a dashboard that spans both implementations should map them.

Django Channels and any ASGI server

Channels consumers run behind ASGI, so one middleware can count every WebSocket scope without touching the consumers. It takes the close code from the websocket.disconnect event, or from the websocket.close event when the server closes:

from prometheus_client import Counter, Gauge

WS_OPEN = Gauge("chatterbox_ws_connections", "Open WebSocket connections")
WS_CLOSED = Counter("chatterbox_ws_closed_total", "WebSocket connections closed, by close code", ["code"])
KNOWN_CODES = {1000, 1001, 1006, 1009, 4400, 4401, 4403, 4408, 4413, 4426, 4429, 4503}


class WebSocketMetrics:
    """ASGI middleware: wraps any ASGI app (Django Channels, Starlette, FastAPI)."""

    def __init__(self, app):
        self.app = app

    async def __call__(self, scope, receive, send):
        if scope["type"] != "websocket":
            return await self.app(scope, receive, send)

        code = 1006  # what the browser reports when no close frame arrives

        async def watching_receive():
            nonlocal code
            event = await receive()
            if event["type"] == "websocket.disconnect":
                code = event.get("code", 1006)
            return event

        async def watching_send(event):
            nonlocal code
            if event["type"] == "websocket.close":
                code = event.get("code", 1000)
            await send(event)

        WS_OPEN.inc()
        try:
            await self.app(scope, watching_receive, watching_send)
        finally:
            WS_OPEN.dec()
            WS_CLOSED.labels(str(code) if code in KNOWN_CODES else "other").inc()

Wrap the router the way the Channels page wraps its other middleware, for example application = ProtocolTypeRouter({"websocket": WebSocketMetrics(AuthMiddlewareStack(URLRouter(...)))}), and expose the registry on a private port with start_http_server. A test with three scripted scopes (a normal 1000 disconnect, a server close with 4429 and a 1001 disconnect) produced one count per code and the gauge back at 0. Under several worker processes each process has its own registry: set PROMETHEUS_MULTIPROC_DIR and use the multiprocess collector, or scrape every process.

Laravel Reverb

Reverb ships a Pulse integration: add the ReverbConnections and ReverbMessages recorders to config/pulse.php with a sample_rate, add the two Livewire cards to the Pulse dashboard, and run php artisan pulse:check on one server only. Pulse stores aggregates in the application database and is a dashboard, not an alerting source. To feed Prometheus, register listeners with Event::listen for MessageReceived and MessageSent and increment counters in a store that a private /metrics route renders, for example a Redis hash, because the Reverb server is a long-running process of its own. The configuration is on Laravel Reverb.

Swoole

Swoole\Server::stats() returns the connection count and the accept, close and request counters of the server. The example renders them in the Prometheus text format on a second listener that speaks plain HTTP, and keeps its own message counters in a Swoole\Table so that all worker processes share them:

// Metrics port: plain HTTP, no WebSocket upgrade, not published by the proxy.
$metrics = $server->addlistener('0.0.0.0', 18692, SWOOLE_SOCK_TCP);
$metrics->set(['open_http_protocol' => true, 'open_websocket_protocol' => false]);
// ...
$server->on('request', function (Request $req, Response $res) use ($server, $counters) {
    if ($req->server['server_port'] !== 18692 || $req->server['request_uri'] !== '/metrics') {
        $res->status(404);
        $res->end();
        return;
    }
    $stats = $server->stats();   // connection_num, accept_count, close_count, worker_request_count, ...
    $lines = [
        '# TYPE chatterbox_ws_connections gauge',
        'chatterbox_ws_connections ' . $stats['connection_num'],
        '# TYPE chatterbox_ws_accepted_total counter',
        'chatterbox_ws_accepted_total ' . $stats['accept_count'],
        '# TYPE chatterbox_ws_closed_total counter',
        'chatterbox_ws_closed_total ' . $stats['close_count'],
        '# TYPE chatterbox_ws_messages_total counter',
        'chatterbox_ws_messages_total{direction="in"} ' . $counters->get('messages_in', 'value'),
        'chatterbox_ws_messages_total{direction="out"} ' . $counters->get('messages_out', 'value'),
    ];
    $res->header('Content-Type', 'text/plain; version=0.0.4');
    $res->end(implode("\n", $lines) . "\n");
});

With two WebSocket clients connected and one closed after three echoed frames, the endpoint reported chatterbox_ws_accepted_total 3, chatterbox_ws_closed_total 1, three messages in and three out. The connection gauge read 2 for one open WebSocket because the scrape itself is a connection; subtract one or accept the offset. A request for /metrics on the public WebSocket port returned 404, so the endpoint is reachable only on the private one. Do not iterate $server->connections from a worker to count sockets: with more than one worker it did not see the connections of the other workers in this test.

XMPP servers

Prosody includes mod_http_openmetrics (the community module mod_prometheus is obsolete): enable it in modules_enabled, set statistics to internal and statistics_interval to manual in the global section, and it serves OpenMetrics at http://localhost:5280/metrics. For ejabberd, mod_prometheus lives in the ejabberd-contrib repository; its sample configuration adds an ejabberd_http listener with a /metrics request handler and a list of hooks to count, so the per-stanza and per-session series are the ones you define. Scrape both on a private port. The server choice is on XMPP Servers.

TURN and media servers

coturn

coturn 4.18 serves Prometheus metrics when started with --prometheus (or prometheus in turnserver.conf) on port 9641 and path /metrics; --prometheus-address, --prometheus-port and --prometheus-path change that. The sample stack below runs it with a time-limited credential secret, as in ICE, STUN and TURN. After a STUN binding request and a TURN allocation with two exchanges through turnutils_uclient, the endpoint listed, among others:

  • stun_binding_request, stun_binding_response and stun_binding_error: the STUN side of ICE gathering.

  • turn_total_allocations (a gauge with a type label): live allocations, the number that sizes the relay.

  • turn_total_traffic_rcvb, turn_total_traffic_sentb and the packet variants: relayed bytes and packets, the bandwidth bill. The turn_traffic_* series add a realm label and are counted when sessions finish.

  • turn_auth_credential_failures: rejected credentials by cause, which separates expired time-limited credentials from wrong ones and from unknown users.

  • turn_unauthenticated_401_requests, _responses and _dropped_responses, plus the turn_ratelimit_* gauges: the 401 challenge and its rate limit, which show a scanner.

  • turn_packet_processed, turn_packet_dropped and the turn_udp_* counters: server pressure.

--prometheus-username-labels adds a username label to the series; leave it off, because it is one series per user. --prometheus-tls serves the endpoint over HTTPS. Do not publish the port; the security advice for TURN is on ICE, STUN and TURN.

LiveKit

LiveKit exposes Prometheus metrics when prometheus.port is set in its configuration; there is no endpoint otherwise:

port: 7880
# ...
prometheus:
  port: 6789

With no participants the endpoint listed livekit_room_total, livekit_participant_total, livekit_quality_score, livekit_quality_rating, livekit_forward_latency_ns, livekit_room_duration_seconds, livekit_node_packet_total and the webhook queue length. The per-stream packet metrics (livekit_packet_total, livekit_packet_loss_percent, livekit_jitter_us, livekit_rtt_ms, livekit_nack_total, livekit_pli_total) are defined in the server source with direction, source, type and country labels and appear once media flows. The quality score and the loss and RTT histograms are the server’s view of the call, the counterpart of the client’s getStats() report. Alert on the share of low quality scores and on webhook queue growth, and read LiveKit for the ports.

mediasoup

mediasoup is a library, so the application owns the exporter. The observer events announce every worker, router, transport and consumer, which lets gauges be maintained without polling, and worker.getResourceUsage() supplies CPU time per worker process:

import * as mediasoup from 'mediasoup';
import { Gauge, Registry } from '@prometheus-io/client';

export const registry = new Registry();
const workers = new Map(); // pid -> Worker

const cpu = new Gauge({ name: 'huddle_mediasoup_worker_cpu_seconds', help: 'User + system CPU time used by a worker process',
  labelNames: ['worker'], registers: [registry],
  async collect() {
    for (const [pid, w] of workers) {
      const u = await w.getResourceUsage();
      this.set({ worker: String(pid) }, (u.ru_utime + u.ru_stime) / 1000); // the fields are in milliseconds
    }
  } });
const transports = new Gauge({ name: 'huddle_mediasoup_transports', help: 'Transports per worker', labelNames: ['worker'], registers: [registry] });
const consumers = new Gauge({ name: 'huddle_mediasoup_consumers', help: 'Consumers per worker', labelNames: ['worker'], registers: [registry] });

// mediasoup.observer announces every new worker, router, transport and consumer, so counting needs no polling.
export function watch() {
  mediasoup.observer.on('newworker', (worker) => {
    const pid = String(worker.pid);
    workers.set(worker.pid, worker);
    transports.set({ worker: pid }, 0);
    consumers.set({ worker: pid }, 0);
    worker.observer.on('close', () => { workers.delete(worker.pid); transports.remove({ worker: pid }); consumers.remove({ worker: pid }); });
    worker.observer.on('newrouter', (router) => router.observer.on('newtransport', (transport) => {
      transports.inc({ worker: pid });
      transport.observer.on('close', () => transports.dec({ worker: pid }));
      transport.observer.on('newconsumer', (consumer) => {
        consumers.inc({ worker: pid });
        consumer.observer.on('close', () => consumers.dec({ worker: pid }));
      });
    }));
  });
}

Source: mediasoup: API

Creating a worker, a router and two plain transports and then closing one showed huddle_mediasoup_transports at 2 and then 1 for the worker’s pid, consumers at 0 and the CPU gauge in seconds. Label by worker, never by transport or consumer id. Use the CPU gauge, not the room count, to decide when to add a worker, as mediasoup explains.

Prometheus setup, alerts and tests

The sample stack runs the Chatterbox server, coturn, LiveKit, an OpenTelemetry Collector and Prometheus on one Compose network. Only the WebSocket port, the collector’s OTLP port and the Prometheus UI are published, all on loopback; the metrics ports exist only on the Compose network. The LiveKit and collector services follow the same pattern and their configuration is shown in the sections on LiveKit and tracing (excerpt):

name: chatterbox-observability
services:
  chatterbox:
    image: node:24-alpine
    working_dir: /app
    command: ["node", "src/server.js"]
    environment:
      APP_HOST: 0.0.0.0
      METRICS_HOST: 0.0.0.0        # reachable on the compose network only; see the published ports below
      OTEL_EXPORTER_OTLP_ENDPOINT: http://otel-collector:4318
      OTEL_SERVICE_NAME: chatterbox
    volumes:
      - ./chatterbox:/app:ro
    ports:
      - "127.0.0.1:18601:18601"    # WebSocket endpoint
      # 18602 (metrics) is deliberately not published
  coturn:
    image: coturn/coturn:4.18.0
    command: >
      turnserver -n --realm=example.test --use-auth-secret --static-auth-secret=change-me
      --no-tls --min-port=49160 --max-port=49200
      --prometheus --prometheus-address=0.0.0.0
    ports:
      - "127.0.0.1:18631:3478/udp"
  # livekit and otel-collector: see the sections on LiveKit and tracing
  prometheus:
    image: prom/prometheus:v3.15.1
    command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.retention.time=2d"]
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
      - ./rules:/etc/prometheus/rules:ro
    ports:
      - "127.0.0.1:18610:9090"

The scrape configuration is short because the targets are static; on Kubernetes use service discovery instead (Kubernetes observability, Service Discovery and Relabeling):

global:
  scrape_interval: 15s
  evaluation_interval: 15s

rule_files:
  - /etc/prometheus/rules/*.yml

scrape_configs:
  - job_name: chatterbox
    static_configs:
      - targets: ["chatterbox:18602"]
  - job_name: coturn
    static_configs:
      - targets: ["coturn:9641"]
  - job_name: livekit
    static_configs:
      - targets: ["livekit:6789"]

Five recording rules precompute the ratios, and six alerts watch them, so that dashboards and alerts share one definition (excerpt; the table below lists all six alerts):

groups:
  - name: chatterbox-recording
    rules:
      - record: chatterbox:ws_connections:sum
        expr: sum(chatterbox_ws_connections)
      - record: chatterbox:fanout_seconds:p99_5m
        expr: histogram_quantile(0.99, sum by (le) (rate(chatterbox_fanout_seconds_bucket[5m])))
      - record: chatterbox:ws_abnormal_close_ratio:5m
        expr: |
          sum(rate(chatterbox_ws_closed_total{code=~"1006|4408|4503"}[5m]))
          /
          sum(rate(chatterbox_ws_closed_total[5m]))
      - record: chatterbox:ws_opened:rate1m
        expr: sum(rate(chatterbox_ws_opened_total[1m]))
      # ...
  - name: chatterbox-alerts
    rules:
      # ...
      - alert: ChatterboxFanoutSlow
        expr: chatterbox:fanout_seconds:p99_5m > 0.25
        for: 10m
        labels: { severity: ticket }
        annotations:
          summary: "p99 fan-out latency is {{ $value | humanizeDuration }}"
      - alert: ChatterboxAbnormalCloseRatioHigh
        expr: chatterbox:ws_abnormal_close_ratio:5m > 0.2
        for: 5m
        labels: { severity: page }
        annotations:
          summary: "{{ $value | humanizePercentage }} of closed sockets ended with 1006, 4408 or 4503"

What each alert says and what to do:

Alert Meaning First checks

ChatterboxTargetDown

Prometheus cannot scrape the node

Crash, network, a metrics port that moved

ChatterboxFanoutSlow

p99 hand-off above 250 ms for 10 minutes

Event loop lag, a huge room, a slow consumer, a CPU-bound handler

ChatterboxAbnormalCloseRatioHigh

More than 20% of closes are 1006, 4408 or 4503

Proxy idle timeout, network problem, deploy in progress

ChatterboxReconnectStorm

New connections per second exceed 5% of the open connections

A restart, a proxy flap, a client reconnect bug without jitter

ChatterboxSlowConsumers

A socket holds more than 1 MiB unsent

A stuck mobile client; add a send-buffer limit that closes the socket

ChatterboxIceFailureRatioHigh

More than 10% of reported ICE attempts failed

TURN down or blocked, credentials expiring, a firewall range

Test the rules before they reach production. promtool test rules replays synthetic series and checks which alerts fire. Keep the test files outside the directory that Prometheus loads with the rules/*.yml glob: Prometheus refuses to start when the glob matches a file that is not a rule file, and a test file is one:

rule_files:
  - ../rules/chatterbox.yml
evaluation_interval: 1m
tests:
  - interval: 1m
    input_series:
      # 40% of the closed sockets end abnormally: 6 of 15 closes per minute
      - series: 'chatterbox_ws_closed_total{code="1006"}'
        values: '0+6x15'
      - series: 'chatterbox_ws_closed_total{code="1000"}'
        values: '0+9x15'
    alert_rule_test:
      - eval_time: 3m
        alertname: ChatterboxAbnormalCloseRatioHigh
        exp_alerts: []
      - eval_time: 12m
        alertname: ChatterboxAbnormalCloseRatioHigh
        exp_alerts:
          - exp_labels: { severity: page }
            exp_annotations:
              summary: "40% of closed sockets ended with 1006, 4408 or 4503"
promtool check config prometheus.yml
promtool check rules rules/chatterbox.yml
promtool test rules tests/chatterbox.test.yml

All three printed SUCCESS (eleven rules found, both test groups passed). With the stack running, the Prometheus API listed the three jobs as up, all eleven rules as ok, and chatterbox:ws_connections:sum as a series. A trap in the second test: comparing histogram_quantile output to a literal such as 0.99 fails on floating-point noise (0.9900000000000001), so the test wraps the expression in round(..., 0.01).

For routing, silences and on-call, see Alertmanager; for the rule syntax Recording and Alerting Rules; for dashboards Grafana Dashboards and Visualization.

Distributed tracing with OpenTelemetry

A trace answers a question that metrics cannot: where did this one message spend its time? A chat message crosses at least the handler, the persistence layer and, behind a backplane, a second node. The rules that make traces useful for WebSockets differ from HTTP:

  • One span per envelope, not one per connection. A connection lasts hours; a span that long cannot be exported or read. Start a span when an envelope is handled and end it when the handler returns. SignalR’s hub-method spans have no parent for the same reason.

  • Link, do not parent, across the connection. If you want to relate an envelope to the connection that carried it, put the connection id in an attribute and in the logs. A span link to a short "connection" span is possible but rarely worth it.

  • Propagate across the backplane, not across the wire protocol. The Chatterbox envelope is fixed by its specification, so the trace context does not travel inside it. It travels inside the internal message that one node publishes on Redis, Kafka or NATS for the others (a W3C traceparent field), which is where the interesting hand-off happens.

  • Sample. A busy room produces thousands of envelopes per second. Keep traces for errors and slow envelopes with tail sampling in the collector, or use a ratio sampler in the SDK, and do not create spans for typing, presence and receipt frames at all.

  • Low-cardinality names, high-cardinality attributes. The span name is chatterbox message; the room is the attribute messaging.destination.name. The OpenTelemetry semantic conventions have a messaging group (used here) and a SignalR group for .NET, but none for WebSocket frames.

The SDK setup is a few lines. With @opentelemetry/sdk-node the context manager and the W3C propagators are registered by start(), and a batch processor sends spans to the collector given by OTEL_EXPORTER_OTLP_ENDPOINT:

import { trace, context, propagation, SpanKind, SpanStatusCode } from '@opentelemetry/api';
import { NodeSDK } from '@opentelemetry/sdk-node';
import { BatchSpanProcessor, SimpleSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';

// The exporter reads OTEL_EXPORTER_OTLP_ENDPOINT itself; an empty processor list would leave tracing off.
const spanProcessors = [];
if (process.env.OTEL_EXPORTER_OTLP_ENDPOINT) spanProcessors.push(new BatchSpanProcessor({ exporter: new OTLPTraceExporter() }));
if (process.env.OTEL_CONSOLE === '1') spanProcessors.push(new SimpleSpanProcessor({ exporter: new ConsoleSpanExporter() }));
const sdk = new NodeSDK({
  serviceName: 'chatterbox',
  spanProcessors,
});
sdk.start(); // registers the AsyncLocalStorage context manager and the W3C propagators
process.on('SIGTERM', () => sdk.shutdown()); // flushes the batch processor

export const tracer = trace.getTracer('chatterbox.server');
export function withSpan(name, attributes, fn, options = {}) {
  return tracer.startActiveSpan(name, { kind: SpanKind.SERVER, attributes, ...options }, (span) => {
    try { return fn(span); }
    catch (err) { span.recordException(err); span.setStatus({ code: SpanStatusCode.ERROR }); throw err; }
    finally { span.end(); }
  });
}
export function inject() { const c = {}; propagation.inject(context.active(), c); return c; }
export function extract(carrier) { return propagation.extract(context.active(), carrier); }
export { context, trace };

Two details to know. First, NodeSDK treats an empty spanProcessors array as "configured with nothing": no tracer provider is created, spans are non-recording, and every trace id is all zeros. The code therefore builds the list conditionally, so tracing stays off unless OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_CONSOLE is set. Second, the span processors take an options object (new BatchSpanProcessor({ exporter })) in the 3.0 SDK, where earlier releases took the exporter as the first argument.

The handler spans are already in server.js through withSpan. For the backplane hop, the publisher injects the context into the internal message and the subscriber extracts it. The demo below stands in for Redis with an EventEmitter; the two spans are what a real publisher and subscriber produce:

// Two "nodes" joined by a backplane. An EventEmitter stands in for a Redis channel:
// the trace context travels inside the backplane message, never in the Chatterbox envelope.
import { EventEmitter } from 'node:events';
import { SpanKind } from '@opentelemetry/api';
import { tracer, inject, extract } from './tracing.js';
import { log } from './log.js';

const backplane = new EventEmitter();

// Node B: consumer. The extracted context makes the new span a child of the publisher's span.
backplane.on('chatterbox:general', (raw) => {
  const msg = JSON.parse(raw);
  const parent = extract(msg.traceContext);
  tracer.startActiveSpan('backplane receive', { kind: SpanKind.CONSUMER, attributes: { 'messaging.system': 'redis', 'messaging.destination.name': 'chatterbox:general' } }, parent, (span) => {
    log.info({ event: 'fanout_local', recipients: 3 }, 'delivered to local sockets');
    span.end();
  });
});

// Node A: handles a client message, then publishes it.
tracer.startActiveSpan('chatterbox message', { kind: SpanKind.SERVER }, (handler) => {
  tracer.startActiveSpan('backplane publish', { kind: SpanKind.PRODUCER, attributes: { 'messaging.system': 'redis', 'messaging.destination.name': 'chatterbox:general' } }, (pub) => {
    backplane.emit('chatterbox:general', JSON.stringify({ envelope: { type: 'message' }, traceContext: inject() }));
    pub.end();
  });
  handler.end();
});

The console exporter printed three spans with the same trace id: chatterbox message (server kind, no parent), backplane publish (producer kind, child of the message span) and backplane receive (consumer kind, child of the publish span), and the log line written inside the subscriber carried that trace id and the receive span id. The collector configuration of the sample stack is:

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
processors:
  batch:
    timeout: 1s
exporters:
  debug:
    verbosity: detailed
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [debug]

In the Compose stack the server sends its handler spans over OTLP to this collector. otelcol-contrib validate accepted the file, and after a test client ran, the debug exporter printed spans named chatterbox hello, chatterbox join, chatterbox message and chatterbox presence, each of kind Server, with messaging.system, messaging.operation.type (process), messaging.operation.name and messaging.destination.name. Replace the debug exporter with your trace backend’s OTLP exporter in production, and add a tail-sampling or probabilistic-sampler processor in front of it.

The current OpenTelemetry JavaScript packages are a new generation: the API is 1.10.0, the SDK packages are 3.0.0 and the experimental packages are 0.300.0. They are not interchangeable with the 2.x generation, whose SDK 2.12 packages declare a peer range that excludes API 1.10, so pin one generation consistently (see Version notes).

Structured logs

Logs are for the single events metrics cannot hold: this connection closed with this code, this user hit this error. Make them machine-readable and joinable:

  • One JSON object per line on standard output, with a fixed vocabulary: event (a short constant such as ws_open, ws_close, protocol_error), conn (the connection id), code (close or error code), and the message.

  • Trace ids in every line written while a span is active, so a log search from a slow trace and a trace search from a log line both work. The pino mixin below adds trace_id and span_id from the active span, and only when the span context is valid.

  • Levels that mean something. info for lifecycle events, warn for protocol errors and abnormal closes, error for bugs. Per-message lines belong at debug, switched on for one connection at a time, because a busy room writes thousands of them per second.

  • No message bodies and no tokens. Log the envelope type, id and size. Never log text, attachments' URLs with signatures, or the one-time ticket from hello.

import pino from 'pino';
import { trace, context } from '@opentelemetry/api';

// One JSON object per line; the active span's ids are added to every record.
export const log = pino({
  base: { service: 'chatterbox' },
  timestamp: pino.stdTimeFunctions.isoTime,
  formatters: { level: (label) => ({ level: label }) },
  mixin() {
    const sc = trace.getSpan(context.active())?.spanContext();
    return sc && trace.isSpanContextValid(sc) ? { trace_id: sc.traceId, span_id: sc.spanId } : {};
  },
});

Source: pino: API

The running server wrote these lines for one connection:

{"level":"info","time":"2026-10-25T00:09:47.589Z","service":"chatterbox","conn":"32f614c4-15de-47f2-b089-a9c1fa12cc01","event":"ws_open","msg":"connection opened"}
{"level":"warn","time":"2026-10-25T00:09:47.800Z","service":"chatterbox","trace_id":"cd657a451ac1c290436d432956d62a1e","span_id":"7026341a9fe1316d","conn":"32f614c4-15de-47f2-b089-a9c1fa12cc01","event":"protocol_error","code":"bad_request","msg":"error envelope sent"}
{"level":"warn","time":"2026-10-25T00:09:47.800Z","service":"chatterbox","conn":"32f614c4-15de-47f2-b089-a9c1fa12cc01","event":"protocol_error","code":"bad_request","msg":"error envelope sent"}
{"level":"info","time":"2026-10-25T00:09:48.103Z","service":"chatterbox","conn":"32f614c4-15de-47f2-b089-a9c1fa12cc01","event":"ws_close","code":1006,"msg":"connection closed"}

The first protocol_error line carries a trace id because it was written inside the handler span of an envelope with an unknown type; the second came from a frame that was not JSON and failed before any span existed, and the ws_open and ws_close lines are outside any envelope. A line without a trace id is normal. Ship the lines with the platform’s log driver (Docker resources, logging and monitoring) rather than writing files from the application.

Dashboards and service level objectives

A dashboard for a real-time service needs five rows: connections and churn, traffic and fan-out, latency percentiles from chatterbox:fanout_seconds:p99_5m, close codes stacked by code, and call quality and TURN. Set objectives on what users feel and tune the numbers against your own load tests (Load Testing and Capacity Planning): a p99 hand-off time of 250 ms, at least 98% of closes outside deploys that are not 1006, 4408 or 4503, and at least 97% of calls that reach connected. Keep causes (CPU, memory, file descriptors, event loop lag) on dashboards and low-urgency alerts. The scaling decisions these numbers feed are on Scaling WebSockets and Scaling WebRTC and TURN.

Common errors and pitfalls

  • High-cardinality labels. A room, user or connection id as a label, or a close reason taken from the client, creates unbounded series. Clamp every label and check with count by (__name__) ({job="chatterbox"}).

  • A gauge that drifts. A gauge incremented on connect but not decremented on every close path (error, terminate, timeout) never returns to zero. Decrement in the single close handler, and use a counter for "opened".

  • Publishing /metrics on the public port. It leaks internals. Use a second listener, a Compose network or a network policy.

  • Averages and absolute connection counts. A mean fan-out time of 5 ms can hide a p99 of 800 ms, and the number of connections follows the time of day. Use quantiles, ratios and rates of change.

  • Treating http_server_requests as WebSocket traffic. It records the upgrade request only. Count frames and close codes yourself.

  • A trace per connection. It never ends and never exports. Trace envelopes and backplane hops.

  • Logging payloads. Chat text is personal data; log sizes and types.

  • Mixing OpenTelemetry package generations. The peer ranges of the 2.x and 3.x SDK packages exclude each other’s API, and a mismatch fails at install time or at import time (SeverityNumber is not exported by the old API).

  • Raw counters on a panel. A deploy resets every counter; graph rate() or increase().

  • Forgetting the client. Server metrics stay green while a TURN misconfiguration fails every call from one network. Only the getStats() reports and the ICE ratio show it.

Version notes

  • prom-client is now @prometheus-io/client. The Prometheus project took over the Node.js client in August 2026. prom-client 15.1.3 is marked deprecated on npm in favour of @prometheus-io/client (0.16.0 on 2026-08-24, 0.16.1 on 2026-08-27), which drops Node.js 16 to 21 and 23 and renames the cluster IPC messages. The API used on this page worked unchanged after changing the import. The unreleased section of its changelog lists a breaking change after 0.16.1: gauges will no longer start at zero, so a gauge without labels can be missing from a scrape until it is first set. Set such gauges at start-up.

  • OpenTelemetry JavaScript 3.0. The API is 1.10.0, the SDK packages are 3.0.0 (the tracing SDK is @opentelemetry/sdk-trace, which replaces sdk-trace-base, and has no Node-specific provider class), and @opentelemetry/sdk-node and the exporters are 0.300.0. There is no @opentelemetry/instrumentation-ws package on npm; an @opentelemetry/instrumentation-socket.io package exists.

  • OpenTelemetry .NET 1.20.1 works with the ASP.NET Core 10 meters and the SignalR ActivitySources; the Prometheus exporter package for ASP.NET Core is still beta. The older SignalR event counters (current-connections and others) still work with dotnet-counters.

  • Spring Boot 4. NettyServerCustomizer is in the package org.springframework.boot.reactor.netty.

  • Prosody. The community mod_prometheus was merged into Prosody as mod_http_openmetrics and is marked obsolete.