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.
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? |
|
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? |
|
counter |
|
How much traffic? |
|
counter |
Messages per second in and out, per envelope type. The out/in ratio is the average fan-out. |
How late is a message? |
|
histogram |
Server-side time to hand a message to every recipient. Alert on the p99, not the average. |
How big is the fan-out? |
|
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? |
|
counter |
1000 and 1001 are healthy; 1006, 4408 and 4503 are the abnormal ones; 4429 and 4413 are abuse or bugs. |
Are clients misbehaving? |
|
counter |
|
Does resume work? |
|
counter |
The share of |
Is someone not reading? |
|
gauge |
The largest unsent buffer. A slow consumer holds memory for everyone. |
Do calls connect? |
|
counter |
ICE failure ratio, and the share of calls that need a TURN relay. |
Are calls good? |
|
histogram |
Quality as the clients measure it, from |
Is the process healthy? |
|
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_secondsstops when the frames are handed to the sockets, not when the browsers receive them. For end-to-end latency use the round trip of thepresenceheartbeat, 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');
Source: ws: API documentation
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 |
Nothing: |
Counters and histograms as in the example above |
Socket.IO (page) |
|
A Prometheus registry (next section); the admin UI is a debugging tool, not monitoring |
SignalR (page) |
|
A |
Spring WebFlux (page) |
Micrometer with Actuator, Reactor Netty server metrics when enabled |
Gauges and counters in the |
python-socketio (page) |
Nothing |
|
Django Channels (page) |
Nothing |
An ASGI middleware that wraps the WebSocket scope |
Laravel Reverb (page) |
Laravel Pulse recorders and cards; the |
A listener that counts the events into your metrics system |
Swoole (page) |
|
A Prometheus text endpoint on a second listener |
XMPP servers (page) |
Prosody: |
Scrape job and alerts |
coturn, LiveKit |
Built-in Prometheus endpoints |
Scrape job and alerts |
mediasoup |
|
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' }));
});
Source: Socket.IO: Server instance
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());
}
}
Source: Spring Boot: Metrics
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()
Source: prometheus_client: Instrumenting
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()
Source: ASGI: WebSocket specification
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.
Source: Laravel: Reverb monitoring
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");
});
Source: Swoole: Server::stats()
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_responseandstun_binding_error: the STUN side of ICE gathering. -
turn_total_allocations(a gauge with atypelabel): live allocations, the number that sizes the relay. -
turn_total_traffic_rcvb,turn_total_traffic_sentband the packet variants: relayed bytes and packets, the bandwidth bill. Theturn_traffic_*series add arealmlabel 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,_responsesand_dropped_responses, plus theturn_ratelimit_*gauges: the 401 challenge and its rate limit, which show a scanner. -
turn_packet_processed,turn_packet_droppedand theturn_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.
Source: coturn: Prometheus metrics
LiveKit
LiveKit exposes Prometheus metrics when prometheus.port is set in its configuration; there is no endpoint
otherwise:
port: 7880
# ...
prometheus:
port: 6789
Source: LiveKit: config-sample.yaml
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"
Source: Docker: Compose file reference
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"]
Source: Prometheus: Configuration
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"
Source: Prometheus: Alerting rules
What each alert says and what to do:
| Alert | Meaning | First checks |
|---|---|---|
|
Prometheus cannot scrape the node |
Crash, network, a metrics port that moved |
|
p99 hand-off above 250 ms for 10 minutes |
Event loop lag, a huge room, a slow consumer, a CPU-bound handler |
|
More than 20% of closes are 1006, 4408 or 4503 |
Proxy idle timeout, network problem, deploy in progress |
|
New connections per second exceed 5% of the open connections |
A restart, a proxy flap, a client reconnect bug without jitter |
|
A socket holds more than 1 MiB unsent |
A stuck mobile client; add a send-buffer limit that closes the socket |
|
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
traceparentfield), 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,presenceandreceiptframes at all. -
Low-cardinality names, high-cardinality attributes. The span name is
chatterbox message; the room is the attributemessaging.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 asws_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
mixinbelow addstrace_idandspan_idfrom the active span, and only when the span context is valid. -
Levels that mean something.
infofor lifecycle events,warnfor protocol errors and abnormal closes,errorfor bugs. Per-message lines belong atdebug, 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 fromhello.
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
closehandler, and use a counter for "opened". -
Publishing
/metricson 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_requestsas 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 (
SeverityNumberis not exported by the old API). -
Raw counters on a panel. A deploy resets every counter; graph
rate()orincrease(). -
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-client15.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 replacessdk-trace-base, and has no Node-specific provider class), and@opentelemetry/sdk-nodeand the exporters are 0.300.0. There is no@opentelemetry/instrumentation-wspackage on npm; an@opentelemetry/instrumentation-socket.iopackage 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-connectionsand others) still work withdotnet-counters. -
Spring Boot 4.
NettyServerCustomizeris in the packageorg.springframework.boot.reactor.netty. -
Prosody. The community
mod_prometheuswas merged into Prosody asmod_http_openmetricsand is marked obsolete.