Skip to content

Commit d1d4e9d

Browse files
committed
Add structured logging (JSONL) + optional Prometheus /metrics
Two independent outputs from the same instrumentation events, no overlap: 1. Logs to stderr. Text formatter (default) for dev; JSON Lines (ESET_MCP_LOG_FORMAT=json) for prod log shippers. Every layer emits a typed 'event' record with low-cardinality fields - tool_call, token_refresh, http_request_complete, http_request_retry, server_starting, onprem_default. 2. Prometheus metrics at /metrics (opt-in via ESET_MCP_METRICS_ENABLED=true). Mounted on the same uvicorn server but bypasses the basic-auth middleware - scrapers can't speak Basic, and /metrics carries no secrets. Protect at the network layer. Counters: esetmcp_tool_calls_total{tool,deployment,status} esetmcp_token_refresh_total{deployment,reason} esetmcp_http_retries_total{deployment,status} esetmcp_responses_capped_total{tool} Histograms: esetmcp_tool_duration_seconds{tool,deployment} esetmcp_response_bytes{tool,deployment} Gauge: esetmcp_client_pool_size prometheus_client is an OPTIONAL dependency (pip install eset-mcp[metrics]). When missing, all metric helpers are no-ops and /metrics returns a 503 with a hint. Defensive secrets gate (the whole point): The log_event() helper applies a hard-coded deny-list before any field reaches a formatter. Anything whose key contains 'password', 'secret', 'token', 'authorization', 'cookie', 'api_key', 'bearer', 'credentials', or 'cf-access-client-*' is dropped entirely - no '****' redaction, no 'value present but hidden' marker. Even if a future caller accidentally passes Authorization=..., it never appears in any log artefact under any circumstance. Path templates from the OpenAPI spec are logged ('/v1/devices/{deviceUuid}') but the SUBSTITUTED path (with the actual UUID) is never logged - that would leak tenant data through request-id correlation. Username (api@firma.tld) is logged - useful for per-tenant debugging, not a secret. NEVER appears as a metric label (would explode cardinality). Test coverage: tests/test_observability.py adds 36 unit tests: - Sensitive-key matcher against 15 representative keys - _scrub() removes sensitive keys, keeps safe ones - log_event() drops sensitive fields end-to-end (asserts no leakage in the serialised line text, not just the parsed record) - JSON formatter produces parseable lines with ISO-Z timestamps - Text formatter appends scrubbed extras - configure_logging() is idempotent (no handler stacking) - All metric helpers callable; /metrics returns Prometheus content-type and contains the expected metric names/labels - /metrics ignores lifespan scope without crashing Snyk Code: 0 issues in eset_mcp/observability/. Ruff: clean. Tests: 136/136 unit + 11 RO smoke against real ESET passed.
1 parent 0c5b423 commit d1d4e9d

14 files changed

Lines changed: 924 additions & 31 deletions

.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,21 @@ ESET_MCP_RESPONSE_BYTES_MAX=100000
7272
# ─── Optional logging level ────────────────────────────────────────────────
7373
ESET_LOG_LEVEL=INFO
7474

75+
# ─── Observability (logs + metrics) ────────────────────────────────────────
76+
# Log output format:
77+
# text - human-readable single line per record (default, good for dev)
78+
# json - JSON Lines, one self-contained object per line (recommended in
79+
# prod - Loki/Vector/Fluent Bit/Datadog all parse it out of the box)
80+
ESET_MCP_LOG_FORMAT=text
81+
82+
# Prometheus /metrics endpoint (HTTP transport only). Opt-in. When true,
83+
# the metrics route is mounted on the same uvicorn server but bypasses
84+
# basic-auth (Prometheus scrapers don't speak Basic auth; /metrics carries
85+
# no secrets). Protect at the network/firewall layer instead.
86+
# Requires `pip install eset-mcp[metrics]` for the prometheus_client dep.
87+
ESET_MCP_METRICS_ENABLED=false
88+
ESET_MCP_METRICS_PATH=/metrics
89+
7590
# ─── Production deployment (docker compose --profile prod) ─────────────────
7691
# Public domain Caddy will request a Let's Encrypt cert for. Must point at
7792
# the host running docker-compose. Only used by the `prod` profile.

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,24 @@ HTTP errors are mapped to readable hints: 403 → check Permission Sets in
133133
ESET PROTECT Hub; 401 → server refreshes the token automatically; 429 →
134134
back off; 5xx → retry shortly.
135135

136+
### Observability (logs + Prometheus)
137+
138+
- **Structured logs** to stderr. Text (default) for dev, JSON Lines for
139+
prod log shippers via `ESET_MCP_LOG_FORMAT=json`. Every tool call,
140+
token refresh, HTTP retry and pool eviction emits a typed `event`
141+
record with low-cardinality fields (tool, deployment, status,
142+
duration_ms, response_bytes, ...).
143+
- **Prometheus metrics** at an opt-in `/metrics` endpoint
144+
(`ESET_MCP_METRICS_ENABLED=true`, requires
145+
`pip install eset-mcp[metrics]`). Counters for tool calls, token
146+
refreshes, HTTP retries, cap hits; histograms for tool duration and
147+
response sizes; gauge for client pool size.
148+
- **What never enters logs or metrics**: passwords, `Authorization`
149+
headers, CF Access secrets, request/response bodies, query strings,
150+
substituted path parameters (which can leak UUIDs). A defensive
151+
deny-list in the logger strips known-sensitive keys before any
152+
formatter sees them.
153+
136154
---
137155

138156
## Security
@@ -273,6 +291,9 @@ All settings live in `.env`. Required fields are marked in
273291
| `ESET_MCP_HTTP_PORT` | `8765` | HTTP port |
274292
| `ESET_MCP_RESPONSE_BYTES_MAX` | `100000` | Per-call response byte cap; `0` disables |
275293
| `ESET_LOG_LEVEL` | `INFO` | `DEBUG` / `INFO` / `WARNING` / `ERROR` |
294+
| `ESET_MCP_LOG_FORMAT` | `text` | `text` (dev, human-readable) or `json` (prod log shippers) |
295+
| `ESET_MCP_METRICS_ENABLED` | `false` | Mount Prometheus `/metrics`; requires `eset-mcp[metrics]` |
296+
| `ESET_MCP_METRICS_PATH` | `/metrics` | Where to mount the metrics endpoint |
276297
| `ESET_DEPLOYMENT` | `cloud` | `cloud` (ESET Connect) or `onprem` (customer-hosted PROTECT) |
277298
| `ESET_ONPREM_SERVER_URL` | - | `https://host[:port]` of the on-prem console (req. in env+onprem) |
278299
| `ESET_ONPREM_VERIFY_SSL` | `true` | Set `false` for on-prem consoles with self-signed certs |

eset_mcp/__main__.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,36 @@
1-
"""Server entrypoint stdio or Streamable HTTP transport."""
1+
"""Server entrypoint - stdio or Streamable HTTP transport."""
22
from __future__ import annotations
33

44
import asyncio
55
import logging
6-
import sys
76

87
from .client_pool import ClientPool
98
from .config import Settings
109
from .credentials import BasicAuthCredentialResolver, EnvCredentialResolver
10+
from .observability import configure_logging, log_event
1111
from .server import build_server
1212

1313

1414
def main() -> None:
1515
settings = Settings.from_env()
16-
logging.basicConfig(
17-
level=settings.log_level,
18-
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
19-
stream=sys.stderr, # MCP stdio uses stdout for JSON-RPC — logs MUST go to stderr.
20-
)
16+
configure_logging(level=settings.log_level, fmt=settings.log_format)
2117
log = logging.getLogger("eset_mcp")
22-
log.info(
23-
"ESET-MCP starting — mode=%s region=%s transport=%s auth=%s deployment=%s",
24-
settings.mode, settings.region, settings.transport, settings.auth_mode, settings.deployment,
18+
log_event(
19+
log, "server_starting",
20+
mode=settings.mode,
21+
region=settings.region,
22+
transport=settings.transport,
23+
auth_mode=settings.auth_mode,
24+
deployment=settings.deployment,
25+
metrics_enabled=settings.metrics_enabled,
26+
log_format=settings.log_format,
2527
)
2628
if settings.deployment == "onprem":
27-
log.info("ESET-MCP on-prem default URL: %s (verify_ssl=%s)",
28-
settings.onprem_server_url or "(none — must be supplied per request)",
29-
settings.onprem_verify_ssl)
29+
log_event(
30+
log, "onprem_default",
31+
server_url=settings.onprem_server_url or "(none - must be supplied per request)",
32+
verify_ssl=settings.onprem_verify_ssl,
33+
)
3034

3135
asyncio.run(_run(settings))
3236

@@ -74,15 +78,34 @@ async def _lifespan(_app):
7478
async with manager.run():
7579
yield
7680

77-
app = Starlette(
78-
routes=[Mount("/mcp", app=manager.handle_request)],
79-
lifespan=_lifespan,
80-
)
81-
82-
# Basic-auth middleware sits in front of everything in `basic` mode.
81+
routes = [Mount("/mcp", app=manager.handle_request)]
82+
# Optional /metrics endpoint - mounted BEFORE the basic-auth middleware
83+
# is added so that Prometheus scrapers don't need to send Basic auth
84+
# to pull metrics. The endpoint carries no secrets and is intended to
85+
# be protected at the network layer (private subnet / VPN / Caddy ACL).
86+
if settings.metrics_enabled:
87+
from .observability.metrics import metrics_asgi_app, metrics_available
88+
routes.append(Mount(settings.metrics_path, app=metrics_asgi_app()))
89+
if not metrics_available():
90+
logging.getLogger("eset_mcp").warning(
91+
"ESET_MCP_METRICS_ENABLED=true but prometheus_client is not "
92+
"installed - %s will return 503. Install via "
93+
"'pip install eset-mcp[metrics]'.",
94+
settings.metrics_path,
95+
)
96+
97+
app = Starlette(routes=routes, lifespan=_lifespan)
98+
99+
# Basic-auth middleware sits in front of everything in `basic` mode -
100+
# EXCEPT the /metrics route, which was added above. We skip the
101+
# middleware for that path so scrapers don't get 401'd.
83102
if settings.auth_mode == "basic":
84103
from .middleware import BasicAuthCredentialsMiddleware
85-
app.add_middleware(BasicAuthCredentialsMiddleware, settings=settings)
104+
app.add_middleware(
105+
BasicAuthCredentialsMiddleware,
106+
settings=settings,
107+
skip_paths=(settings.metrics_path,) if settings.metrics_enabled else (),
108+
)
86109

87110
config = uvicorn.Config(
88111
app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level.lower()

eset_mcp/auth.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from __future__ import annotations
2121

2222
import asyncio
23+
import logging
2324
import time
2425
from dataclasses import dataclass
2526
from typing import Protocol
@@ -28,8 +29,11 @@
2829

2930
from .credentials import Credentials
3031
from .errors import map_http_error
32+
from .observability import inc_token_refresh, log_event
3133
from .regions import resolve_auth_url
3234

35+
_LOG = logging.getLogger("eset_mcp.auth")
36+
3337
# Safety margin — refresh the token N seconds before its nominal expiry.
3438
# Cloud uses 5 min (1h tokens, plenty of headroom); on-prem mirrors the
3539
# reference TypeScript client at 60 s.
@@ -71,16 +75,29 @@ def _expired_or_expiring(self, *, margin: int | None = None) -> bool:
7175
async def get_access_token(self) -> str:
7276
async with self._lock:
7377
if self._expired_or_expiring():
78+
reason = "initial" if self._token is None else "proactive"
7479
await self._refresh_locked()
80+
self._record_refresh(reason)
7581
assert self._token is not None
7682
return self._token.access_token
7783

7884
async def force_refresh(self) -> str:
7985
async with self._lock:
8086
await self._refresh_locked()
87+
self._record_refresh("forced_401")
8188
assert self._token is not None
8289
return self._token.access_token
8390

91+
def _record_refresh(self, reason: str) -> None:
92+
"""Emit metric + log entry for a successful token refresh."""
93+
inc_token_refresh(deployment=self._creds.deployment, reason=reason)
94+
log_event(
95+
_LOG, "token_refresh",
96+
deployment=self._creds.deployment,
97+
reason=reason,
98+
user=self._creds.user,
99+
)
100+
84101
async def _refresh_locked(self) -> None: # pragma: no cover - abstract
85102
raise NotImplementedError
86103

eset_mcp/client_pool.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from .config import Settings
2020
from .credentials import Credentials
2121
from .http_client import EsetHttpClient
22+
from .observability import set_pool_size
2223

2324
# Hard cap on concurrently-cached clients. Realistic multi-tenant MCP
2425
# deployments fronting an ESET MSP will see well under 100 active credentials
@@ -64,12 +65,13 @@ async def get(self, creds: Credentials) -> EsetHttpClient:
6465
client = EsetHttpClient(creds)
6566
self._clients[key] = client
6667
await self._maybe_evict_locked()
68+
set_pool_size(len(self._clients))
6769
return client
6870

6971
async def _maybe_evict_locked(self) -> None:
7072
while len(self._clients) > self._max:
7173
_, evicted = self._clients.popitem(last=False)
72-
# Close in background don't block the get() that triggered eviction.
74+
# Close in background - don't block the get() that triggered eviction.
7375
task = asyncio.create_task(evicted.aclose())
7476
self._eviction_tasks.add(task)
7577
task.add_done_callback(self._eviction_tasks.discard)
@@ -79,6 +81,7 @@ async def close(self) -> None:
7981
async with self._lock:
8082
clients = list(self._clients.values())
8183
self._clients.clear()
84+
set_pool_size(0)
8285
# Best-effort close of all clients in parallel.
8386
await asyncio.gather(*(c.aclose() for c in clients), return_exceptions=True)
8487

eset_mcp/config.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ class Settings:
6161
onprem_cf_access_client_id: str
6262
onprem_cf_access_client_secret: str
6363

64+
# ─── Observability ────────────────────────────────────────────────────
65+
# ``text`` (default) - human-readable single line per record, ideal for
66+
# local dev. ``json`` - JSON Lines, ideal for log shippers in prod.
67+
log_format: str
68+
# Opt-in Prometheus /metrics endpoint (HTTP transport only). When
69+
# enabled, the metrics route is mounted alongside /mcp on the same
70+
# uvicorn server but bypasses the basic-auth middleware (Prometheus
71+
# scrapers don't speak Basic auth, and /metrics carries no secrets).
72+
# Protect at the network layer instead.
73+
metrics_enabled: bool
74+
metrics_path: str
75+
6476
@property
6577
def has_env_credentials(self) -> bool:
6678
return bool(self.user and self.password)
@@ -142,6 +154,12 @@ def from_env(cls, env_file: Path | None = None) -> Settings:
142154
),
143155
onprem_cf_access_client_id=cf_id,
144156
onprem_cf_access_client_secret=cf_secret,
157+
log_format=_parse_log_format(os.getenv("ESET_MCP_LOG_FORMAT", "text")),
158+
metrics_enabled=_parse_bool(
159+
os.getenv("ESET_MCP_METRICS_ENABLED", "false"),
160+
"ESET_MCP_METRICS_ENABLED",
161+
),
162+
metrics_path=os.getenv("ESET_MCP_METRICS_PATH", "/metrics"),
145163
)
146164

147165

@@ -191,6 +209,13 @@ def _parse_int(raw: str, var: str) -> int:
191209
return n
192210

193211

212+
def _parse_log_format(raw: str) -> str:
213+
val = raw.split("#", 1)[0].strip().lower()
214+
if val not in ("text", "json"):
215+
raise RuntimeError(f"ESET_MCP_LOG_FORMAT must be 'text' or 'json', got {raw!r}")
216+
return val
217+
218+
194219
def _parse_bool(raw: str, var: str) -> bool:
195220
cleaned = raw.split("#", 1)[0].strip().lower()
196221
if cleaned in ("true", "1", "yes", "on"):

eset_mcp/http_client.py

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from .auth import TokenManagerProto, make_token_manager
2929
from .credentials import Credentials
3030
from .errors import EsetApiError, map_http_error
31+
from .observability import inc_http_retry, log_event
3132
from .regions import resolve_base_url
3233

3334
_LOG = logging.getLogger("eset_mcp.http")
@@ -119,27 +120,49 @@ async def request(
119120
json: Any | None = None,
120121
_retry_after_401: bool = True,
121122
) -> dict[str, Any] | list[Any]:
122-
"""Execute a request, handle 202/401/429/5xx, return the body as JSON."""
123+
"""Execute a request, handle 202/401/429/5xx, return the body as JSON.
124+
125+
``path`` is the *template* path from the OpenAPI spec (with
126+
``{placeholder}`` segments already substituted by the caller). We
127+
intentionally do NOT log the substituted URL - it can reveal device
128+
UUIDs and other tenant data. Logs carry only the template-shaped
129+
path the caller passed plus the upstream status code.
130+
"""
123131
url = resolve_base_url(self._creds, service) + path
124132
token = await self._token_mgr.get_access_token()
125133
headers = {"Authorization": f"Bearer {token}", "Accept": "application/json"}
126134

135+
import time as _time
136+
t0 = _time.monotonic()
127137
resp = await self._http.request(method, url, params=query, json=json, headers=headers)
128138

129-
# 401 token expired between get_access_token() and the request; force-refresh + retry.
139+
# 401 - token expired between get_access_token() and the request; force-refresh + retry.
130140
if resp.status_code == 401 and _retry_after_401:
131-
_LOG.info("401 from %s — forcing token refresh and retrying", url)
141+
inc_http_retry(deployment=self._creds.deployment, status="401")
142+
log_event(
143+
_LOG, "http_request_retry",
144+
deployment=self._creds.deployment,
145+
method=method, service=service, status=401,
146+
reason="token_expired",
147+
)
132148
await self._token_mgr.force_refresh()
133149
return await self.request(
134150
method, service, path, query=query, json=json, _retry_after_401=False
135151
)
136152

137-
# 429 rate limit, backoff.
153+
# 429 - rate limit, backoff.
138154
if resp.status_code == 429:
139155
for attempt in range(1, _RATE_LIMIT_MAX_TRIES + 1):
140156
retry_after = _parse_retry_after(resp.headers.get("Retry-After"))
141157
wait_s = retry_after or _RATE_LIMIT_BASE_BACKOFF_S * (2 ** (attempt - 1))
142-
_LOG.warning("429 from %s — sleeping %.1fs (try %d/%d)", url, wait_s, attempt, _RATE_LIMIT_MAX_TRIES)
158+
inc_http_retry(deployment=self._creds.deployment, status="429")
159+
log_event(
160+
_LOG, "http_request_retry", level=logging.WARNING,
161+
deployment=self._creds.deployment,
162+
method=method, service=service, status=429,
163+
reason="rate_limited", wait_s=round(wait_s, 2),
164+
attempt=attempt, max_attempts=_RATE_LIMIT_MAX_TRIES,
165+
)
143166
await asyncio.sleep(wait_s)
144167
resp = await self._http.request(method, url, params=query, json=json, headers=headers)
145168
if resp.status_code != 429:
@@ -153,13 +176,27 @@ async def request(
153176
return await self._poll_pending(method, url, response_id, headers, query, json)
154177

155178
if resp.status_code >= 400:
179+
log_event(
180+
_LOG, "http_request_complete", level=logging.WARNING,
181+
deployment=self._creds.deployment,
182+
method=method, service=service, status=resp.status_code,
183+
duration_ms=int((_time.monotonic() - t0) * 1000),
184+
request_id=resp.headers.get("request-id"),
185+
)
156186
raise map_http_error(
157187
status=resp.status_code,
158188
body=resp.text,
159189
endpoint=url,
160190
request_id=resp.headers.get("request-id"),
161191
)
162192

193+
log_event(
194+
_LOG, "http_request_complete",
195+
deployment=self._creds.deployment,
196+
method=method, service=service, status=resp.status_code,
197+
duration_ms=int((_time.monotonic() - t0) * 1000),
198+
request_id=resp.headers.get("request-id"),
199+
)
163200
return _parse_json(resp)
164201

165202
async def _poll_pending(

eset_mcp/middleware.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,23 @@
5151

5252

5353
class BasicAuthCredentialsMiddleware(BaseHTTPMiddleware):
54-
"""Require Basic auth on every HTTP request and stash creds in a ContextVar."""
54+
"""Require Basic auth on every HTTP request and stash creds in a ContextVar.
5555
56-
def __init__(self, app: ASGIApp, *, settings: Settings):
56+
``skip_paths`` lets specific routes bypass auth entirely - used for the
57+
Prometheus ``/metrics`` endpoint, which carries no secrets and is
58+
expected to be reachable by scrapers that can't speak Basic auth.
59+
Protect those routes at the network layer instead.
60+
"""
61+
62+
def __init__(self, app: ASGIApp, *, settings: Settings, skip_paths: tuple[str, ...] = ()):
5763
super().__init__(app)
5864
self._settings = settings
65+
self._skip_paths = tuple(skip_paths)
5966

6067
async def dispatch(self, request: Request, call_next) -> Response:
68+
# Routes the operator opted out of auth for (e.g. /metrics).
69+
if self._skip_paths and request.url.path in self._skip_paths:
70+
return await call_next(request)
6171
auth_header = request.headers.get("authorization", "")
6272
region_header = request.headers.get("x-eset-region")
6373
server_url_header = request.headers.get("x-eset-server-url")

0 commit comments

Comments
 (0)