Skip to content

Commit a916d96

Browse files
feature(metrics): SSH tunnel observability and Grafana dashboard
Add per-build attribution for SSH-tunneled client traffic and a Grafana dashboard to observe it, proving traffic flows through the tunnel and surfacing proxy vitals. Client (argus/client/session.py): - Emit X-Argus-Build-Id (JOB_NAME#BUILD_NUMBER, ARGUS_BUILD_ID override) and X-Argus-Build-Url on tunneled requests only. Backend (argus_backend.py): - Add http_request_tunnel_build_total{build_id, build_url} counter; build_url is 1:1 with build_id (no extra series), carried for linking. Dashboard (scripts/grafana/argus-overview.json): - Make datasource portable via a ${datasource} template variable. - Add "SSH Tunneling" row (tunneled-vs-direct proof, % via tunnel, header anomalies, SSH auth-attempt rate, registrations, by-endpoint, by-UA, by-build with a clickable Jenkins data link). - Add "SSH Proxy Vitals" row (bandwidth/CPU/mem/disk/connections) gated on a proxy_job variable; requires node_exporter on the proxy host. Tests: cover build-id composition, build-url, and omission outside CI.
1 parent 018654d commit a916d96

4 files changed

Lines changed: 2589 additions & 0 deletions

File tree

argus/client/session.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,45 @@ def _resolve_use_tunnel(use_tunnel: bool | None) -> bool:
2727
return os.environ.get("ARGUS_USE_TUNNEL", "").strip().lower() in ("1", "true", "yes", "on")
2828

2929

30+
# Upper bound on the build-id header value to keep the backend's per-build
31+
# Prometheus label cardinality bounded even if a client sends junk.
32+
_MAX_BUILD_ID_LEN = 256
33+
34+
35+
def _resolve_build_id() -> str | None:
36+
"""Resolve a human-identifiable build id for tunnel attribution.
37+
38+
Order of preference:
39+
40+
1. ``ARGUS_BUILD_ID`` — explicit override, used verbatim.
41+
2. Jenkins ``JOB_NAME`` (full folder path) combined with ``BUILD_NUMBER``
42+
when available, formatted as ``job/path#42`` for easy identification.
43+
44+
Returns ``None`` outside CI so the ``X-Argus-Build-Id`` header is simply
45+
omitted.
46+
"""
47+
override = os.environ.get("ARGUS_BUILD_ID", "").strip()
48+
if override:
49+
return override[:_MAX_BUILD_ID_LEN]
50+
51+
job_name = os.environ.get("JOB_NAME", "").strip()
52+
if not job_name:
53+
return None
54+
build_number = os.environ.get("BUILD_NUMBER", "").strip()
55+
build_id = f"{job_name}#{build_number}" if build_number else job_name
56+
return build_id[:_MAX_BUILD_ID_LEN]
57+
58+
59+
def _resolve_build_url() -> str | None:
60+
"""Jenkins ``BUILD_URL`` (or ``ARGUS_BUILD_URL`` override) for the run.
61+
62+
Carried as ``X-Argus-Build-Url`` so the Grafana ``build_id`` series can link
63+
straight back to the originating build. ``None`` when not running in CI.
64+
"""
65+
value = (os.environ.get("ARGUS_BUILD_URL") or os.environ.get("BUILD_URL") or "").strip()
66+
return value[:_MAX_BUILD_ID_LEN * 2] if value else None
67+
68+
3069
def _resolve_monitor_interval() -> float:
3170
raw = os.environ.get("ARGUS_TUNNEL_MONITOR_INTERVAL")
3271
if raw is None:
@@ -82,6 +121,8 @@ def __init__(self, auth_token: str, original_base_url: str, max_retries: int = 3
82121

83122
self._auth_token = auth_token
84123
self._original_base_url = original_base_url
124+
self._build_id = _resolve_build_id()
125+
self._build_url = _resolve_build_url()
85126

86127
self._tunnel: SSHTunnel | None = None
87128
self._tunnel_config: TunnelConfig | None = None
@@ -252,6 +293,10 @@ def _tunnel_headers(self) -> dict[str, str]:
252293
}
253294
if self._tunnel_config.key_id:
254295
headers["X-Forwarded-Key-ID"] = self._tunnel_config.key_id
296+
if self._build_id:
297+
headers["X-Argus-Build-Id"] = self._build_id
298+
if self._build_url:
299+
headers["X-Argus-Build-Url"] = self._build_url
255300
return headers
256301

257302
def request(self, method: str, url: str, *args, **kwargs) -> requests.Response:

argus/client/tests/test_tunnel.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,67 @@ def test_tunneled_session_close_unregisters_atexit():
388388
callback(ref)
389389

390390

391+
def _prime_tunnel_state(session):
392+
"""Force a session into the 'tunnel active' state so _tunnel_headers() emits."""
393+
from types import SimpleNamespace
394+
395+
session._tunnel_port = 12345
396+
session._tunnel_established_at = "2026-06-16T00:00:00+00:00"
397+
session._tunnel_config = SimpleNamespace(proxy_host="proxy.example.com", key_id="key-uuid")
398+
399+
400+
def test_tunnel_headers_explicit_build_id_used_verbatim(monkeypatch):
401+
monkeypatch.setenv("ARGUS_BUILD_ID", "custom-id#7")
402+
session = TunneledSession(auth_token="token", original_base_url="https://argus.scylladb.com")
403+
try:
404+
_prime_tunnel_state(session)
405+
headers = session._tunnel_headers()
406+
assert headers["X-Argus-Build-Id"] == "custom-id#7"
407+
assert headers["X-SSH-Tunnel-Origin"] == "proxy.example.com"
408+
finally:
409+
session.close()
410+
411+
412+
def test_tunnel_headers_compose_job_name_and_build_number(monkeypatch):
413+
monkeypatch.delenv("ARGUS_BUILD_ID", raising=False)
414+
monkeypatch.setenv("JOB_NAME", "scylla-master/longevity/longevity-100gb")
415+
monkeypatch.setenv("BUILD_NUMBER", "42")
416+
monkeypatch.setenv("BUILD_URL", "https://jenkins.scylladb.com/job/scylla-master/job/longevity/42/")
417+
session = TunneledSession(auth_token="token", original_base_url="https://argus.scylladb.com")
418+
try:
419+
_prime_tunnel_state(session)
420+
headers = session._tunnel_headers()
421+
assert headers["X-Argus-Build-Id"] == "scylla-master/longevity/longevity-100gb#42"
422+
assert headers["X-Argus-Build-Url"] == "https://jenkins.scylladb.com/job/scylla-master/job/longevity/42/"
423+
finally:
424+
session.close()
425+
426+
427+
def test_tunnel_headers_build_id_job_name_without_build_number(monkeypatch):
428+
monkeypatch.delenv("ARGUS_BUILD_ID", raising=False)
429+
monkeypatch.delenv("BUILD_NUMBER", raising=False)
430+
monkeypatch.setenv("JOB_NAME", "jenkins-job-name")
431+
session = TunneledSession(auth_token="token", original_base_url="https://argus.scylladb.com")
432+
try:
433+
_prime_tunnel_state(session)
434+
assert session._tunnel_headers()["X-Argus-Build-Id"] == "jenkins-job-name"
435+
finally:
436+
session.close()
437+
438+
439+
def test_tunnel_headers_omit_build_id_and_url_when_unset(monkeypatch):
440+
for var in ("ARGUS_BUILD_ID", "JOB_NAME", "BUILD_NUMBER", "ARGUS_BUILD_URL", "BUILD_URL"):
441+
monkeypatch.delenv(var, raising=False)
442+
session = TunneledSession(auth_token="token", original_base_url="https://argus.scylladb.com")
443+
try:
444+
_prime_tunnel_state(session)
445+
headers = session._tunnel_headers()
446+
assert "X-Argus-Build-Id" not in headers
447+
assert "X-Argus-Build-Url" not in headers
448+
finally:
449+
session.close()
450+
451+
391452
def test_argus_client_works_as_context_manager(requests_mock, monkeypatch):
392453
requests_mock.get(
393454
"https://argus.scylladb.com/api/v1/client/testrun/test-type/test-id/get",

argus_backend.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,21 @@ def register_metrics():
7979
},
8080
)
8181
)
82+
METRICS.register_default(
83+
METRICS.counter(
84+
"http_request_tunnel_build_total",
85+
"Tunneled requests by Jenkins build/job id (X-Argus-Build-Id)",
86+
labels={
87+
# One series per Jenkins build (job/path#42). Requests without the
88+
# header (non-tunnel or pre-attribution clients) fall into the
89+
# "unknown" bucket and are filtered out in dashboards.
90+
"build_id": lambda: request.headers.get("X-Argus-Build-Id") or "unknown",
91+
# 1:1 with build_id (no extra series) — carried so Grafana can
92+
# link the build_id straight back to the Jenkins build.
93+
"build_url": lambda: request.headers.get("X-Argus-Build-Url") or "",
94+
},
95+
)
96+
)
8297

8398

8499
def start_server(config=None) -> Flask:

0 commit comments

Comments
 (0)