Skip to content

feat(prometheus): stream active connections, termination status and bandwidth - #13796

Open
AlinsRan wants to merge 6 commits into
masterfrom
feat/stream-observability-metrics
Open

feat(prometheus): stream active connections, termination status and bandwidth#13796
AlinsRan wants to merge 6 commits into
masterfrom
feat/stream-observability-metrics

Conversation

@AlinsRan

Copy link
Copy Markdown
Contributor

Description

A TCP or UDP proxy cannot be monitored from the Stream metrics APISIX has today. apisix_stream_connection_total is a per-Route connection counter, and three things are missing behind it:

  • No live concurrency. Connection counts are only known once a session ends.
  • No way to tell a failure from a clean close. NGINX reports a Stream $status of 200 for every failure that happens after the upstream connection is established — every post-connect path ends in ngx_stream_proxy_finalize(s, NGX_STREAM_OK) — so an idle timeout or a reset is indistinguishable from a normal close.
  • No throughput. $bytes_sent and friends only exist at log time, and Lua has no handle on a live session, so a long-lived connection reports nothing until it ends.

This adds three metrics, all keyed by listen_addr rather than by Route: a session can end before any Stream Route is matched, and the byte counters come from NGINX, which only knows the listening address.

Metric Type Labels
apisix_stream_active_connections gauge listen_addr
apisix_stream_status counter code, listen_addr, node
apisix_stream_bandwidth counter listen_addr, type, side

code only ever takes values NGINX itself uses for Stream sessions — no synthetic codes. $stream_session_reason narrows 200 down to genuine closes and folds timeouts and resets onto 400/502. A plugin rejection is carried on ctx instead, because a Stream plugin rejects by closing the session (plugin.lua's run_plugin calls ngx_exit(1)), so the code it returned never reaches $status; it is mapped through the same whitelist so an operator-supplied rejected_code cannot put an arbitrary value on the metric.

apisix_stream_bandwidth and apisix_stream_active_connections are read once a second from an NGINX shared memory zone, so they keep moving while a connection is open. Zone size is nginx_config.stream.metrics_zone_size, default 1m.

Runtime dependency

apisix_stream_metrics_zone, $stream_session_reason and $stream_listen_addr come from apisix-nginx-module 1.19.9, so .requirements moves to 1.3.14, the first APISIX-Runtime carrying it.

Everything degrades on an older runtime rather than breaking: the collector logs once and stays off, and the status metric falls back to $status.

Which issue(s) this PR fixes

N/A

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible

…andwidth

A TCP or UDP proxy could not be monitored from the stream metrics we had. The
only one was a per route connection counter, nginx reports a stream $status of
200 for every failure that happens after the upstream connection is
established, and no byte counter of a live session is reachable from Lua, so a
long-lived connection produced nothing until it ended.

Add three metrics, keyed by listen_addr because a session can end before any
stream route is matched and because the byte counters come from nginx, which
only knows the listening address:

- apisix_stream_active_connections, live concurrency per listening address
- apisix_stream_status, one count per finished session. $stream_session_reason
  narrows 200 down to genuine closes and folds timeouts and resets onto the
  codes nginx itself uses; plugin rejections are carried on ctx because a
  stream plugin rejects by closing the session, which never sets $status
- apisix_stream_bandwidth, bytes per direction and side, read once a second
  from the nginx zone so it grows while a connection is still open

apisix_stream_metrics_zone, $stream_session_reason and $stream_listen_addr
come from apisix-nginx-module 1.19.9, so .requirements moves to the first
runtime carrying it. Everything degrades on an older runtime: the collector
logs once and stays off, and the status metric falls back to $status.
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. enhancement New feature or request plugin labels Aug 10, 2026
ci/linux-install-openresty.sh guards the debug deb with a hardcoded version
and sha256 per architecture, and bails out when .requirements moves without
them. Every job that installs the runtime failed on that guard.

Digests taken from the published 1.3.14 release assets.
The live-gauge case scraped the metrics endpoint while its session was open
and found no series, although the later case asserting 0 on the same series
passed -- so the collector had published, just not before this block scraped.
Whether it has ticked by then depends on when the stream plugins finish
loading, which is not what this case is about.

Read the zone directly, which is where the count lives while the session is
open; the published gauge stays covered by the case after it.
The zone was published by a per-second timer pinned to worker 0, because
the bandwidth counter needs a total-to-delta conversion and the baseline
sat in worker memory, which only one worker could own.

Moving the baseline into the metric dict removes that constraint: every
worker claims against the same value, so the conversion can run wherever
the scrape lands. The range is claimed with an atomic incr so two
concurrent scrapes cannot both count it -- the loser hands the excess
back.

Drops up to two seconds of staleness (one timer tick plus one counter
sync), the gap where a reload lost the bytes between the old worker's
last tick and the new worker's first, and the timer itself.
The zone backing the bandwidth and active connection metrics is declared
in the stream block, and it is now read while the exposition is built.
The scraping blocks were generated without a stream block, so the reload
into them dropped the zone and the scrape had nothing to read: bandwidth
never left its baseline and the gauge kept whatever the previous block
left behind.

Publishing used to happen on a timer in the stream subsystem, which ran
in the blocks that did have one, so this only surfaced now.
The atomic claim clamped the baseline to the claiming reader's own zone
sample, so a reader whose sample was older than a concurrent one pulled
the baseline backwards and the overlapping bytes were counted twice on
the next read. The rewind path had the same shape: it could not tell a
recreated zone from a claim still in flight.

Serialise the conversion instead, with an expiring key in the metric
dict. A reader that loses the race publishes nothing and the delta it
skipped is picked up by the next one; the gauge is unaffected because it
carries no baseline.

Also stop latching the zone probe. The zone comes and goes with the
configuration, and caching a miss left a worker blind for good once it
came back.

The endpoint serves a cache the privileged agent refills on
refresh_interval, so the tests pin that interval short: at the default
15s no block lives long enough to observe its own traffic.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request plugin size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant