Skip to content

Commit 92357d2

Browse files
bbangertludeeus
andauthored
Add server operating metrics (#422)
* Add server-side metrics reporting --------- Co-authored-by: Joakim Sørensen <[email protected]>
1 parent 6facf8f commit 92357d2

File tree

16 files changed

+731
-6
lines changed

16 files changed

+731
-6
lines changed

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ target-version = "py311"
5252
ignore = [
5353
"A005", # https://docs.astral.sh/ruff/rules/stdlib-module-shadowing/
5454
"ASYNC110", # https://docs.astral.sh/ruff/rules/async-busy-wait/
55-
"ANN101", # https://docs.astral.sh/ruff/rules/missing-type-self/
5655
"EM101", # https://docs.astral.sh/ruff/rules/raw-string-in-exception/
5756
"EM102", # https://docs.astral.sh/ruff/rules/f-string-in-exception/
5857
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
@@ -61,8 +60,9 @@ ignore = [
6160
"PERF203", # https://docs.astral.sh/ruff/rules/try-except-in-loop/
6261
"S101", # https://docs.astral.sh/ruff/rules/assert/
6362
"S104", # https://docs.astral.sh/ruff/rules/hardcoded-bind-all-interfaces/
64-
"TCH001", # https://docs.astral.sh/ruff/rules/typing-only-first-party-import/
65-
"TCH003", # https://docs.astral.sh/ruff/rules/typing-only-standard-library-import/
63+
"TC001", # https://docs.astral.sh/ruff/rules/typing-only-first-party-import/
64+
"TC003", # https://docs.astral.sh/ruff/rules/typing-only-standard-library-import/
65+
"TC006", # https://docs.astral.sh/ruff/rules/runtime-cast-value/
6666
"TID252", # https://docs.astral.sh/ruff/rules/relative-imports/
6767
"TRY003", # https://docs.astral.sh/ruff/rules/raise-vanilla-args/
6868
"TRY301", # https://docs.astral.sh/ruff/rules/raise-within-try/
@@ -104,6 +104,7 @@ zip-safe = false
104104
[tool.setuptools.packages.find]
105105
include = [
106106
"snitun",
107+
"snitun.metrics",
107108
"snitun.server",
108109
"snitun.client",
109110
"snitun.multiplexer",

snitun/metrics/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""SniTun metrics collection system."""
2+
3+
from .base import MetricsCollector
4+
from .factory import MetricsFactory, create_noop_metrics_collector
5+
6+
__all__ = [
7+
"MetricsCollector",
8+
"MetricsFactory",
9+
"create_noop_metrics_collector",
10+
]

snitun/metrics/base.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Base metrics collector interface."""
2+
3+
from abc import ABC, abstractmethod
4+
5+
6+
class MetricsCollector(ABC):
7+
"""Abstract base class for metrics collection."""
8+
9+
@abstractmethod
10+
def gauge(
11+
self,
12+
name: str,
13+
value: float,
14+
tags: dict[str, str] | None = None,
15+
) -> None:
16+
"""
17+
Set a gauge metric to a specific value.
18+
19+
Args:
20+
name: Metric name (e.g., 'snitun.peer.connections')
21+
value: Current value
22+
tags: Optional tags for the metric
23+
"""
24+
25+
@abstractmethod
26+
def increment(
27+
self,
28+
name: str,
29+
value: float = 1,
30+
tags: dict[str, str] | None = None,
31+
) -> None:
32+
"""
33+
Increment a counter metric.
34+
35+
Args:
36+
name: Metric name (e.g., 'snitun.connections.new')
37+
value: Amount to increment (default: 1)
38+
tags: Optional tags for the metric
39+
"""
40+
41+
@abstractmethod
42+
def histogram(
43+
self,
44+
name: str,
45+
value: float,
46+
tags: dict[str, str] | None = None,
47+
) -> None:
48+
"""
49+
Record a value in a histogram.
50+
51+
Args:
52+
name: Metric name (e.g., 'snitun.connection.duration')
53+
value: Value to record
54+
tags: Optional tags for the metric
55+
"""
56+
57+
@abstractmethod
58+
def timing(
59+
self,
60+
name: str,
61+
value: float,
62+
tags: dict[str, str] | None = None,
63+
) -> None:
64+
"""
65+
Record a timing value.
66+
67+
Args:
68+
name: Metric name (e.g., 'snitun.handshake.time')
69+
value: Time in milliseconds
70+
tags: Optional tags for the metric
71+
"""

snitun/metrics/factory.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
"""Factory functions for creating metrics collectors."""
2+
3+
from collections.abc import Callable
4+
5+
from .base import MetricsCollector
6+
from .noop import NoOpMetricsCollector
7+
8+
# Type alias for metrics factory function
9+
MetricsFactory = Callable[[], MetricsCollector]
10+
11+
12+
def create_noop_metrics_collector() -> MetricsCollector:
13+
"""
14+
Create a no-op metrics collector for zero overhead.
15+
16+
Returns:
17+
NoOpMetricsCollector instance that does nothing.
18+
"""
19+
return NoOpMetricsCollector()

snitun/metrics/noop.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""No-operation metrics collector for zero overhead."""
2+
3+
from .base import MetricsCollector
4+
5+
6+
class NoOpMetricsCollector(MetricsCollector):
7+
"""No-operation metrics collector with zero overhead."""
8+
9+
def gauge(
10+
self,
11+
name: str,
12+
value: float,
13+
tags: dict[str, str] | None = None,
14+
) -> None:
15+
"""No-op gauge implementation."""
16+
17+
def increment(
18+
self,
19+
name: str,
20+
value: float = 1,
21+
tags: dict[str, str] | None = None,
22+
) -> None:
23+
"""No-op increment implementation."""
24+
25+
def histogram(
26+
self,
27+
name: str,
28+
value: float,
29+
tags: dict[str, str] | None = None,
30+
) -> None:
31+
"""No-op histogram implementation."""
32+
33+
def timing(
34+
self,
35+
name: str,
36+
value: float,
37+
tags: dict[str, str] | None = None,
38+
) -> None:
39+
"""No-op timing implementation."""

snitun/server/listener_peer.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import logging
88

99
from ..exceptions import SniTunChallengeError, SniTunInvalidPeer
10+
from ..metrics.base import MetricsCollector
1011
from ..utils.asyncio import asyncio_timeout
1112
from .peer_manager import PeerManager
1213

@@ -23,11 +24,13 @@ def __init__(
2324
peer_manager: PeerManager,
2425
host: str | None = None,
2526
port: int | None = None,
27+
metrics: MetricsCollector | None = None,
2628
) -> None:
2729
"""Initialize SNI Proxy interface."""
2830
self._peer_manager = peer_manager
2931
self._host = host
3032
self._port = port or 8080
33+
self._metrics = metrics
3134
self._server: asyncio.Server | None = None
3235

3336
async def start(self) -> None:
@@ -69,11 +72,26 @@ async def handle_connection(
6972
# Connection closed before data received
7073
if not fernet_data:
7174
return
75+
76+
# Track authentication attempt
77+
if self._metrics:
78+
self._metrics.increment(
79+
"snitun.auth.attempts",
80+
tags={"result": "started"},
81+
)
82+
7283
peer = self._peer_manager.create_peer(fernet_data)
7384

7485
# Start multiplexer
7586
await peer.init_multiplexer_challenge(reader, writer)
7687

88+
# Authentication successful
89+
if self._metrics:
90+
self._metrics.increment(
91+
"snitun.auth.attempts",
92+
tags={"result": "success"},
93+
)
94+
7795
self._peer_manager.add_peer(peer)
7896
while peer.is_connected:
7997
try:
@@ -85,9 +103,19 @@ async def handle_connection(
85103

86104
except SniTunInvalidPeer:
87105
_LOGGER.debug("Close because invalid fernet data")
106+
if self._metrics:
107+
self._metrics.increment(
108+
"snitun.auth.failures",
109+
tags={"reason": "invalid_token"},
110+
)
88111

89112
except SniTunChallengeError:
90113
_LOGGER.debug("Close because challenge was wrong")
114+
if self._metrics:
115+
self._metrics.increment(
116+
"snitun.auth.failures",
117+
tags={"reason": "challenge_failed"},
118+
)
91119

92120
finally:
93121
if peer:

snitun/server/peer.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,11 @@ def is_ready(self) -> bool:
7777
return False
7878
return self.multiplexer.is_connected
7979

80+
@property
81+
def protocol_version(self) -> int:
82+
"""Return the protocol version."""
83+
return self._protocol_version
84+
8085
async def init_multiplexer_challenge(
8186
self,
8287
reader: asyncio.StreamReader,

snitun/server/peer_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6-
from collections.abc import Callable
6+
from collections.abc import Callable, ValuesView
77
from datetime import UTC, datetime
88
from enum import Enum
99
import json
@@ -47,6 +47,10 @@ def connections(self) -> int:
4747
"""Return count of connected devices."""
4848
return len(self._peers)
4949

50+
def iter_peers(self) -> ValuesView[Peer]:
51+
"""Iterate over all peers."""
52+
return self._peers.values()
53+
5054
def create_peer(self, fernet_data: bytes) -> Peer:
5155
"""Create a new peer from crypt config."""
5256
try:

snitun/server/run.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from typing import Any
1818

1919
from ..exceptions import ParseSNIIncompleteError
20+
from ..metrics import MetricsCollector, MetricsFactory, create_noop_metrics_collector
2021
from ..utils.asyncio import asyncio_timeout
2122
from ..utils.server import MAX_BUFFER_SIZE, MAX_READ_SIZE
2223
from .listener_peer import PeerListener
@@ -198,6 +199,8 @@ def __init__(
198199
port: int | None = None,
199200
worker_size: int | None = None,
200201
throttling: int | None = None,
202+
metrics_factory: MetricsFactory | None = None,
203+
metrics_interval: int = 60,
201204
) -> None:
202205
"""Initialize SniTun Server."""
203206
super().__init__()
@@ -209,6 +212,9 @@ def __init__(
209212
self._worker_size: int = worker_size or (cpu_count() * 2)
210213
self._workers: list[ServerWorker] = []
211214
self._running: bool = False
215+
self._metrics_factory = metrics_factory or create_noop_metrics_collector
216+
self._metrics_interval = metrics_interval
217+
self._metrics: MetricsCollector | None = None
212218

213219
# TCP server
214220
self._server: socket.socket | None = None
@@ -221,10 +227,17 @@ def peer_counter(self) -> int:
221227

222228
def start(self) -> None:
223229
"""Run server."""
230+
self._metrics = self._metrics_factory()
231+
224232
# Init first all worker, we don't want the epoll on the childs
225233
_LOGGER.info("Run SniTun with %d worker", self._worker_size)
226234
for _ in range(self._worker_size):
227-
worker = ServerWorker(self._fernet_keys, throttling=self._throttling)
235+
worker = ServerWorker(
236+
self._fernet_keys,
237+
throttling=self._throttling,
238+
metrics_factory=self._metrics_factory,
239+
metrics_interval=self._metrics_interval,
240+
)
228241
worker.start()
229242
self._workers.append(worker)
230243

0 commit comments

Comments
 (0)