diff --git a/apiforgepy/__init__.py b/apiforgepy/__init__.py index f7e2a20..17bdd02 100644 --- a/apiforgepy/__init__.py +++ b/apiforgepy/__init__.py @@ -24,7 +24,7 @@ from .transport import LocalTransport from .cloud_transport import CloudTransport -__version__ = "2.1.1" +__version__ = "2.2.0" __all__ = ["ApiForgeMiddleware"] diff --git a/apiforgepy/aggregator.py b/apiforgepy/aggregator.py index eaf1ff5..926c0b1 100644 --- a/apiforgepy/aggregator.py +++ b/apiforgepy/aggregator.py @@ -1,3 +1,4 @@ +import json import threading import time import math @@ -33,28 +34,41 @@ def record(self, event: dict): with self._lock: if key not in self._buffer: self._buffer[key] = { - "method": event["method"], - "route": event["route"], - "env": event["env"], - "release": event.get("release"), - "is_ghost": is_ghost, - "durations": [], - "response_sizes": [], - "status_2xx": 0, - "status_4xx": 0, - "status_5xx": 0, + "method": event["method"], + "route": event["route"], + "env": event["env"], + "release": event.get("release"), + "is_ghost": is_ghost, + "durations": [], + "ttfb_durations": [], + "response_sizes": [], + "request_sizes": [], + "inflight_samples": [], + "status_2xx": 0, + "status_3xx": 0, + "status_4xx": 0, + "status_5xx": 0, + "status_map": {}, } bucket = self._buffer[key] bucket["durations"].append(event["duration_ms"]) + + if event.get("ttfb_ms") is not None: + bucket["ttfb_durations"].append(event["ttfb_ms"]) if event.get("response_size") is not None: bucket["response_sizes"].append(event["response_size"]) + if event.get("request_size") is not None: + bucket["request_sizes"].append(event["request_size"]) + if event.get("inflight") is not None: + bucket["inflight_samples"].append(event["inflight"]) + s = event["status"] - if 200 <= s < 300: - bucket["status_2xx"] += 1 - elif 400 <= s < 500: - bucket["status_4xx"] += 1 - elif s >= 500: - bucket["status_5xx"] += 1 + if 200 <= s < 300: bucket["status_2xx"] += 1 + elif 300 <= s < 400: bucket["status_3xx"] += 1 + elif 400 <= s < 500: bucket["status_4xx"] += 1 + elif s >= 500: bucket["status_5xx"] += 1 + + bucket["status_map"][s] = bucket["status_map"].get(s, 0) + 1 def _schedule(self): self._timer = threading.Timer(self._flush_interval, self._tick) @@ -76,29 +90,54 @@ def _flush(self): rows = [] for bucket in snapshot.values(): - sorted_d = sorted(bucket["durations"]) + sorted_d = sorted(bucket["durations"]) + sorted_ttfb = sorted(bucket["ttfb_durations"]) n = len(sorted_d) - sizes = bucket["response_sizes"] - bytes_avg = sum(sizes) / len(sizes) if sizes else None - lat_avg = sum(bucket["durations"]) / n if n > 0 else None + + sizes = bucket["response_sizes"] + req_sizes = bucket["request_sizes"] + inflight = bucket["inflight_samples"] + + bytes_avg = sum(sizes) / len(sizes) if sizes else None + request_size_avg = sum(req_sizes) / len(req_sizes) if req_sizes else None + lat_avg = sum(bucket["durations"]) / n if n > 0 else None + inflight_avg = sum(inflight) / len(inflight) if inflight else None + inflight_max = max(inflight) if inflight else None + + # Granular distribution — sorted by count desc + status_dist = ( + json.dumps( + dict(sorted(bucket["status_map"].items(), key=lambda x: -x[1])) + ) + if bucket["status_map"] else None + ) + rows.append({ - "bucket_ts": bucket_ts, - "route": bucket["route"], - "method": bucket["method"], - "env": bucket["env"], - "release_tag": bucket["release"], - "is_ghost": 1 if bucket["is_ghost"] else 0, - "status_2xx": bucket["status_2xx"], - "status_4xx": bucket["status_4xx"], - "status_5xx": bucket["status_5xx"], - "total_calls": n, - "lat_p50": _percentile(sorted_d, 0.50), - "lat_p90": _percentile(sorted_d, 0.90), - "lat_p99": _percentile(sorted_d, 0.99), - "lat_avg": lat_avg, - "lat_min": sorted_d[0] if sorted_d else 0, - "lat_max": sorted_d[-1] if sorted_d else 0, - "bytes_avg": bytes_avg, + "bucket_ts": bucket_ts, + "route": bucket["route"], + "method": bucket["method"], + "env": bucket["env"], + "release_tag": bucket["release"], + "is_ghost": 1 if bucket["is_ghost"] else 0, + "status_2xx": bucket["status_2xx"], + "status_3xx": bucket["status_3xx"], + "status_4xx": bucket["status_4xx"], + "status_5xx": bucket["status_5xx"], + "status_dist": status_dist, + "total_calls": n, + "lat_p50": _percentile(sorted_d, 0.50), + "lat_p90": _percentile(sorted_d, 0.90), + "lat_p99": _percentile(sorted_d, 0.99), + "lat_avg": lat_avg, + "lat_min": sorted_d[0] if sorted_d else 0, + "lat_max": sorted_d[-1] if sorted_d else 0, + "lat_ttfb_p50": _percentile(sorted_ttfb, 0.50) if sorted_ttfb else None, + "lat_ttfb_p90": _percentile(sorted_ttfb, 0.90) if sorted_ttfb else None, + "lat_ttfb_p99": _percentile(sorted_ttfb, 0.99) if sorted_ttfb else None, + "bytes_avg": bytes_avg, + "request_size_avg": request_size_avg, + "inflight_avg": inflight_avg, + "inflight_max": inflight_max, }) self._transport.write(rows) diff --git a/apiforgepy/database.py b/apiforgepy/database.py index ed35cda..5be19c7 100644 --- a/apiforgepy/database.py +++ b/apiforgepy/database.py @@ -28,23 +28,32 @@ def _init(self): ); CREATE TABLE IF NOT EXISTS api_metrics ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - bucket_ts INTEGER NOT NULL, - route TEXT NOT NULL, - method TEXT NOT NULL, - env TEXT NOT NULL DEFAULT 'production', - release_tag TEXT, - status_2xx INTEGER NOT NULL DEFAULT 0, - status_4xx INTEGER NOT NULL DEFAULT 0, - status_5xx INTEGER NOT NULL DEFAULT 0, - total_calls INTEGER NOT NULL DEFAULT 0, - lat_p50 REAL, - lat_p90 REAL, - lat_p99 REAL, - lat_min REAL, - lat_max REAL, - bytes_avg REAL, - is_ghost INTEGER NOT NULL DEFAULT 0 + id INTEGER PRIMARY KEY AUTOINCREMENT, + bucket_ts INTEGER NOT NULL, + route TEXT NOT NULL, + method TEXT NOT NULL, + env TEXT NOT NULL DEFAULT 'production', + release_tag TEXT, + status_2xx INTEGER NOT NULL DEFAULT 0, + status_3xx INTEGER NOT NULL DEFAULT 0, + status_4xx INTEGER NOT NULL DEFAULT 0, + status_5xx INTEGER NOT NULL DEFAULT 0, + status_dist TEXT, + total_calls INTEGER NOT NULL DEFAULT 0, + lat_p50 REAL, + lat_p90 REAL, + lat_p99 REAL, + lat_avg REAL, + lat_min REAL, + lat_max REAL, + lat_ttfb_p50 REAL, + lat_ttfb_p90 REAL, + lat_ttfb_p99 REAL, + bytes_avg REAL, + request_size_avg REAL, + inflight_avg REAL, + inflight_max INTEGER, + is_ghost INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_route_ts ON api_metrics (route, method, bucket_ts); @@ -52,15 +61,26 @@ def _init(self): CREATE INDEX IF NOT EXISTS idx_release ON api_metrics (release_tag) WHERE release_tag IS NOT NULL; """) + # Migrations for databases created before these columns were introduced - try: - c.execute("ALTER TABLE api_metrics ADD COLUMN bytes_avg REAL") - except Exception: - pass - try: - c.execute("ALTER TABLE api_metrics ADD COLUMN is_ghost INTEGER NOT NULL DEFAULT 0") - except Exception: - pass + migrations = [ + "ALTER TABLE api_metrics ADD COLUMN bytes_avg REAL", + "ALTER TABLE api_metrics ADD COLUMN is_ghost INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE api_metrics ADD COLUMN status_3xx INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE api_metrics ADD COLUMN status_dist TEXT", + "ALTER TABLE api_metrics ADD COLUMN lat_avg REAL", + "ALTER TABLE api_metrics ADD COLUMN lat_ttfb_p50 REAL", + "ALTER TABLE api_metrics ADD COLUMN lat_ttfb_p90 REAL", + "ALTER TABLE api_metrics ADD COLUMN lat_ttfb_p99 REAL", + "ALTER TABLE api_metrics ADD COLUMN request_size_avg REAL", + "ALTER TABLE api_metrics ADD COLUMN inflight_avg REAL", + "ALTER TABLE api_metrics ADD COLUMN inflight_max INTEGER", + ] + for sql in migrations: + try: + c.execute(sql) + except Exception: + pass c.commit() def insert_batch(self, rows: list[dict]): @@ -70,12 +90,22 @@ def insert_batch(self, rows: list[dict]): self._conn.executemany(""" INSERT INTO api_metrics (bucket_ts, route, method, env, release_tag, - status_2xx, status_4xx, status_5xx, total_calls, - lat_p50, lat_p90, lat_p99, lat_min, lat_max, bytes_avg, is_ghost) + status_2xx, status_3xx, status_4xx, status_5xx, status_dist, + total_calls, + lat_p50, lat_p90, lat_p99, lat_avg, lat_min, lat_max, + lat_ttfb_p50, lat_ttfb_p90, lat_ttfb_p99, + bytes_avg, request_size_avg, + inflight_avg, inflight_max, + is_ghost) VALUES ( :bucket_ts, :route, :method, :env, :release_tag, - :status_2xx, :status_4xx, :status_5xx, :total_calls, - :lat_p50, :lat_p90, :lat_p99, :lat_min, :lat_max, :bytes_avg, :is_ghost + :status_2xx, :status_3xx, :status_4xx, :status_5xx, :status_dist, + :total_calls, + :lat_p50, :lat_p90, :lat_p99, :lat_avg, :lat_min, :lat_max, + :lat_ttfb_p50, :lat_ttfb_p90, :lat_ttfb_p99, + :bytes_avg, :request_size_avg, + :inflight_avg, :inflight_max, + :is_ghost ) """, rows) self._conn.commit() @@ -108,6 +138,7 @@ def get_summary(self) -> dict: SELECT SUM(total_calls) as calls_total, SUM(status_2xx) as calls_2xx, + SUM(status_3xx) as calls_3xx, SUM(status_4xx) as calls_4xx, SUM(status_5xx) as calls_5xx, AVG(lat_p90) as avg_p90, @@ -142,15 +173,19 @@ def get_routes(self, hours: int = 24) -> list[dict]: rows = self._conn.execute(""" SELECT route, method, is_ghost, - SUM(total_calls) as calls, - SUM(status_2xx) as calls_2xx, - SUM(status_4xx) as calls_4xx, - SUM(status_5xx) as calls_5xx, - AVG(lat_p50) as p50, - AVG(lat_p90) as p90, - AVG(lat_p99) as p99, - MAX(lat_max) as lat_max, - AVG(bytes_avg) as bytes_avg + SUM(total_calls) as calls, + SUM(status_2xx) as calls_2xx, + SUM(status_3xx) as calls_3xx, + SUM(status_4xx) as calls_4xx, + SUM(status_5xx) as calls_5xx, + AVG(lat_p50) as p50, + AVG(lat_p90) as p90, + AVG(lat_p99) as p99, + MAX(lat_max) as lat_max, + AVG(bytes_avg) as bytes_avg, + AVG(request_size_avg) as request_size_avg, + AVG(inflight_avg) as inflight_avg, + MAX(inflight_max) as inflight_max FROM api_metrics WHERE bucket_ts >= ? GROUP BY route, method, is_ghost @@ -164,7 +199,8 @@ def get_time_series(self, route: str, method: str, hours: int = 24) -> list[dict rows = self._conn.execute(""" SELECT bucket_ts, SUM(total_calls) as calls, AVG(lat_p50) as p50, AVG(lat_p90) as p90, - AVG(lat_p99) as p99, SUM(status_5xx) as errors + AVG(lat_p99) as p99, SUM(status_5xx) as errors, + SUM(status_3xx) as redirects FROM api_metrics WHERE route = ? AND method = ? AND bucket_ts >= ? GROUP BY bucket_ts ORDER BY bucket_ts ASC diff --git a/apiforgepy/middleware.py b/apiforgepy/middleware.py index 0c7784a..c0f3582 100644 --- a/apiforgepy/middleware.py +++ b/apiforgepy/middleware.py @@ -1,11 +1,7 @@ import re import time -import os import threading -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request - _NUMERIC_SEGMENT = re.compile(r"/\d+") _UUID_SEGMENT = re.compile(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE) @@ -39,62 +35,109 @@ def walk(route_list, prefix: str = ""): return routes -class ApiForgeMiddleware(BaseHTTPMiddleware): +class ApiForgeMiddleware: + """ + Raw ASGI middleware for APIForge. + + Captures route, method, status, latency, TTFB, request/response sizes, + and inflight request count — no body content, no PII. + """ + def __init__(self, app, *, aggregator, config: dict): - super().__init__(app) - self._aggregator = aggregator - self._env = config["env"] - self._release = config.get("release") - self._service = config["service"] - self._sampling = config["sampling"] - self._ignore = set(config["ignore_paths"]) - self._store_routes = config.get("store_routes") + self._app = app + self._aggregator = aggregator + self._env = config["env"] + self._release = config.get("release") + self._service = config["service"] + self._sampling = config["sampling"] + self._ignore = set(config["ignore_paths"]) + self._store_routes = config.get("store_routes") self._routes_scanned = False + self._inflight_count = 0 + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + await self._app(scope, receive, send) + return - async def dispatch(self, request: Request, call_next): - path = request.url.path + path = scope.get("path", "/") - if not self._routes_scanned and self._store_routes: + # Route scan — executed once on the first request + if not self._routes_scanned and scope.get("app"): self._routes_scanned = True - routes = _extract_routes(request.app) - if routes: + app_obj = scope["app"] + routes = _extract_routes(app_obj) + if routes and self._store_routes: threading.Thread( target=self._store_routes, args=(routes,), daemon=True ).start() if path in self._ignore: - return await call_next(request) + await self._app(scope, receive, send) + return - if self._sampling < 1.0 and __import__("random").random() > self._sampling: - return await call_next(request) + import random + if self._sampling < 1.0 and random.random() > self._sampling: + await self._app(scope, receive, send) + return start = time.perf_counter() - response = await call_next(request) - duration_ms = (time.perf_counter() - start) * 1000 + self._inflight_count += 1 + inflight_snapshot = self._inflight_count + + # Request body size — size only, never the content + raw_headers = dict(scope.get("headers", [])) + req_cl = raw_headers.get(b"content-length") + request_size = int(req_cl.decode()) if req_cl else None + + # Capture TTFB, status code and response size via ASGI send wrapper + status_code = None + response_size = None + ttfb_ms = None + + async def send_wrapper(message): + nonlocal status_code, response_size, ttfb_ms + if message["type"] == "http.response.start": + if ttfb_ms is None: + ttfb_ms = (time.perf_counter() - start) * 1000 + status_code = message.get("status", 200) + resp_headers = dict(message.get("headers", [])) + cl = resp_headers.get(b"content-length") + if cl: + response_size = int(cl.decode()) + await send(message) try: - # FastAPI exposes the matched route pattern via request.scope["route"] - route_obj = request.scope.get("route") - if route_obj and hasattr(route_obj, "path"): - route_pattern = route_obj.path - else: - route_pattern = _normalize_path(path) - - content_length = response.headers.get("content-length") - - self._aggregator.record({ - "route": route_pattern, - "method": request.method, - "status": response.status_code, - "duration_ms": duration_ms, - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), - "env": self._env, - "release": self._release, - "service": self._service, - "response_size": int(content_length) if content_length else None, - "is_ghost": route_obj is None, - }) - except Exception: - pass # never crash the host application - - return response + await self._app(scope, receive, send_wrapper) + finally: + self._inflight_count -= 1 + + if status_code is None: + return # no http.response.start was sent — nothing to record + + duration_ms = (time.perf_counter() - start) * 1000 + route_obj = scope.get("route") + + try: + if route_obj and hasattr(route_obj, "path"): + route_pattern = route_obj.path + else: + route_pattern = _normalize_path(path) + + self._aggregator.record({ + "route": route_pattern, + "method": scope.get("method", "GET"), + "status": status_code, + "duration_ms": duration_ms, + "ttfb_ms": ttfb_ms if ttfb_ms is not None else duration_ms, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "env": self._env, + "release": self._release, + "service": self._service, + "response_size": response_size, + "request_size": request_size, + "is_ghost": route_obj is None, + "inflight": inflight_snapshot, + }) + except Exception: + pass # never crash the host application diff --git a/pyproject.toml b/pyproject.toml index 010985a..4bbab17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "apiforgepy" -version = "2.1.2" +version = "2.2.0" description = "API observability & intelligence for FastAPI/Starlette — local-first, privacy-first" readme = "README.md" diff --git a/tests/test_aggregator.py b/tests/test_aggregator.py index d7c94b2..4cb6ce9 100644 --- a/tests/test_aggregator.py +++ b/tests/test_aggregator.py @@ -1,3 +1,4 @@ +import json import pytest from apiforgepy.aggregator import Aggregator @@ -15,7 +16,9 @@ def base_event(**overrides): defaults = dict( route="/test", method="GET", status=200, duration_ms=100.0, timestamp="2026-01-01T00:00:00Z", - env="test", release=None, service="svc", response_size=None, + env="test", release=None, service="svc", + response_size=None, request_size=None, + ttfb_ms=None, inflight=None, ) return {**defaults, **overrides} @@ -37,6 +40,15 @@ def test_increments_2xx_counter(self): key = next(iter(agg._buffer)) assert agg._buffer[key]["status_2xx"] == 2 + def test_increments_3xx_counter(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(status=301)) + agg.record(base_event(status=302)) + key = next(iter(agg._buffer)) + assert agg._buffer[key]["status_3xx"] == 2 + assert agg._buffer[key]["status_2xx"] == 0 + def test_increments_4xx_counter(self): t = make_transport() agg = Aggregator(t, flush_interval_ms=999_999_000) @@ -53,6 +65,16 @@ def test_increments_5xx_counter(self): key = next(iter(agg._buffer)) assert agg._buffer[key]["status_5xx"] == 1 + def test_builds_status_map(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(status=200)) + agg.record(base_event(status=200)) + agg.record(base_event(status=404)) + key = next(iter(agg._buffer)) + assert agg._buffer[key]["status_map"][200] == 2 + assert agg._buffer[key]["status_map"][404] == 1 + def test_separate_buckets_per_route(self): t = make_transport() agg = Aggregator(t, flush_interval_ms=999_999_000) @@ -105,6 +127,14 @@ def test_correct_lat_min_max(self): assert row["lat_min"] == pytest.approx(5.0) assert row["lat_max"] == pytest.approx(95.0) + def test_computes_lat_avg(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(duration_ms=10.0)) + agg.record(base_event(duration_ms=30.0)) + agg._flush() + assert t.calls[0][0]["lat_avg"] == pytest.approx(20.0) + def test_total_calls_matches_records(self): t = make_transport() agg = Aggregator(t, flush_interval_ms=999_999_000) @@ -134,3 +164,83 @@ def test_bytes_avg_is_none_when_no_sizes(self): agg.record(base_event(response_size=None)) agg._flush() assert t.calls[0][0]["bytes_avg"] is None + + def test_request_size_avg(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(request_size=1000)) + agg.record(base_event(request_size=3000)) + agg._flush() + assert t.calls[0][0]["request_size_avg"] == pytest.approx(2000.0) + + def test_request_size_avg_is_none_when_absent(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event()) + agg._flush() + assert t.calls[0][0]["request_size_avg"] is None + + def test_status_dist_json_sorted_by_count(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + for _ in range(5): + agg.record(base_event(status=200)) + agg.record(base_event(status=404)) + agg._flush() + dist = json.loads(t.calls[0][0]["status_dist"]) + assert dist["200"] == 5 + assert dist["404"] == 1 + keys = list(dist.keys()) + assert keys[0] == "200" # highest count first + + def test_status_dist_is_none_when_no_events(self): + # Verify a freshly flushed row always has a valid status_dist + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(status=200)) + agg._flush() + row = t.calls[0][0] + assert row["status_dist"] is not None + assert json.loads(row["status_dist"]) # valid JSON + + def test_lat_ttfb_percentiles(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + for i in range(1, 11): + agg.record(base_event(duration_ms=float(i * 10), ttfb_ms=float(i * 5))) + agg._flush() + row = t.calls[0][0] + assert isinstance(row["lat_ttfb_p50"], float) + assert isinstance(row["lat_ttfb_p90"], float) + assert isinstance(row["lat_ttfb_p99"], float) + assert row["lat_ttfb_p99"] <= row["lat_p99"] + + def test_lat_ttfb_is_none_when_absent(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event()) + agg._flush() + row = t.calls[0][0] + assert row["lat_ttfb_p50"] is None + assert row["lat_ttfb_p90"] is None + assert row["lat_ttfb_p99"] is None + + def test_inflight_avg_and_max(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event(inflight=3)) + agg.record(base_event(inflight=7)) + agg.record(base_event(inflight=5)) + agg._flush() + row = t.calls[0][0] + assert row["inflight_avg"] == pytest.approx(5.0) + assert row["inflight_max"] == 7 + + def test_inflight_is_none_when_absent(self): + t = make_transport() + agg = Aggregator(t, flush_interval_ms=999_999_000) + agg.record(base_event()) + agg._flush() + row = t.calls[0][0] + assert row["inflight_avg"] is None + assert row["inflight_max"] is None diff --git a/tests/test_database.py b/tests/test_database.py index 153c103..dc01cd9 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,3 +1,4 @@ +import json import time import pytest from apiforgepy.database import ApiForgeDatabase @@ -15,15 +16,24 @@ def insert_row(db, **overrides): env="test", release_tag=None, status_2xx=1, + status_3xx=0, status_4xx=0, status_5xx=0, + status_dist=None, total_calls=1, lat_p50=50.0, lat_p90=90.0, lat_p99=99.0, + lat_avg=70.0, lat_min=10.0, lat_max=150.0, + lat_ttfb_p50=None, + lat_ttfb_p90=None, + lat_ttfb_p99=None, bytes_avg=None, + request_size_avg=None, + inflight_avg=None, + inflight_max=None, is_ghost=0, ) db.insert_batch([{**defaults, **overrides}]) @@ -79,6 +89,14 @@ def test_sums_5xx_errors(self): assert s["recent"]["calls_5xx"] == 3 db.close() + def test_exposes_calls_3xx_in_summary(self): + db = make_db() + now = int(time.time()) + insert_row(db, status_2xx=0, status_3xx=4, total_calls=4, bucket_ts=now) + s = db.get_summary() + assert s["recent"]["calls_3xx"] == 4 + db.close() + class TestGetTimeSeries: def test_returns_data_for_matching_route(self): @@ -97,6 +115,14 @@ def test_returns_empty_for_missing_route(self): assert rows == [] db.close() + def test_includes_redirects_column(self): + db = make_db() + ts = int(time.time()) - 60 + insert_row(db, route="/redir", method="GET", bucket_ts=ts, status_3xx=2) + rows = db.get_time_series("/redir", "GET", 24) + assert rows[0]["redirects"] == 2 + db.close() + class TestGetDeadCandidates: def test_flags_old_routes(self): @@ -192,3 +218,53 @@ def test_null_bytes_avg_when_not_provided(self): routes = db.get_routes(24) assert routes[0]["bytes_avg"] is None db.close() + + +class TestNewColumns: + def test_stores_and_returns_status_3xx(self): + db = make_db() + insert_row(db, route="/redir", status_2xx=0, status_3xx=5, total_calls=5) + routes = db.get_routes(24) + assert routes[0]["calls_3xx"] == 5 + db.close() + + def test_stores_and_retrieves_status_dist(self): + db = make_db() + dist = json.dumps({200: 10, 201: 2}) + insert_row(db, route="/dist", status_dist=dist) + row = db._conn.execute("SELECT status_dist FROM api_metrics LIMIT 1").fetchone() + assert json.loads(row["status_dist"]) == json.loads(dist) + db.close() + + def test_stores_and_returns_lat_avg(self): + db = make_db() + insert_row(db, route="/avg", lat_avg=42.5) + row = db._conn.execute("SELECT lat_avg FROM api_metrics LIMIT 1").fetchone() + assert row["lat_avg"] == pytest.approx(42.5) + db.close() + + def test_stores_and_returns_lat_ttfb(self): + db = make_db() + insert_row(db, route="/ttfb", lat_ttfb_p50=12.0, lat_ttfb_p90=25.0, lat_ttfb_p99=40.0) + row = db._conn.execute( + "SELECT lat_ttfb_p50, lat_ttfb_p90, lat_ttfb_p99 FROM api_metrics LIMIT 1" + ).fetchone() + assert row["lat_ttfb_p50"] == pytest.approx(12.0) + assert row["lat_ttfb_p90"] == pytest.approx(25.0) + assert row["lat_ttfb_p99"] == pytest.approx(40.0) + db.close() + + def test_stores_and_returns_request_size_avg(self): + db = make_db() + insert_row(db, route="/upload", request_size_avg=1024.0) + routes = db.get_routes(24) + assert routes[0]["request_size_avg"] == pytest.approx(1024.0) + db.close() + + def test_stores_and_returns_inflight(self): + db = make_db() + insert_row(db, route="/busy", inflight_avg=4.5, inflight_max=8) + routes = db.get_routes(24) + assert routes[0]["inflight_avg"] == pytest.approx(4.5) + assert routes[0]["inflight_max"] == 8 + db.close()