Skip to content

Commit bd5e408

Browse files
feat(dev): add protected recent logs endpoint
1 parent 07ab252 commit bd5e408

3 files changed

Lines changed: 75 additions & 0 deletions

File tree

server/app.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
from collections import deque
34
from datetime import datetime, timezone
45
from http import HTTPStatus
56
from time import monotonic
@@ -16,6 +17,24 @@
1617
DEFAULT_PORT = 8000
1718
DEFAULT_CORS_ORIGINS = "http://localhost:3000,http://127.0.0.1:3000"
1819
APP_START_TIME = monotonic()
20+
LOG_BUFFER = deque(maxlen=200)
21+
22+
23+
class InMemoryLogHandler(logging.Handler):
24+
"""Keep recent log records available for developer diagnostics."""
25+
26+
def emit(self, record):
27+
LOG_BUFFER.append(
28+
{
29+
"timestamp": datetime.fromtimestamp(record.created, timezone.utc)
30+
.replace(microsecond=0)
31+
.isoformat()
32+
.replace("+00:00", "Z"),
33+
"level": record.levelname,
34+
"logger": record.name,
35+
"message": record.getMessage(),
36+
}
37+
)
1938

2039

2140
def get_runtime_version() -> str:
@@ -54,6 +73,10 @@ def get_cache_enabled() -> bool:
5473
}
5574

5675

76+
def get_recent_logs(limit: int = 100) -> list[dict[str, str]]:
77+
return list(LOG_BUFFER)[-max(1, min(limit, 200)):]
78+
79+
5780
def _database_dependency_status() -> str:
5881
try:
5982
client = db_connect.connect_db()
@@ -120,6 +143,9 @@ def create_app():
120143
format="%(asctime)s [%(levelname)s] %(message)s",
121144
datefmt="%Y-%m-%d %H:%M:%S",
122145
)
146+
root_logger = logging.getLogger()
147+
if not any(isinstance(handler, InMemoryLogHandler) for handler in root_logger.handlers):
148+
root_logger.addHandler(InMemoryLogHandler())
123149

124150
cors_origins = get_runtime_cors_origins()
125151
CORS(app, resources={r"/*": {"origins": cors_origins}})

server/endpoints.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import os
44
from http import HTTPStatus
55

6+
from flask import request
67
from flask_restx import Resource, Namespace
78

89
import data.countries as countries_db
@@ -12,6 +13,7 @@
1213
APP_NAME,
1314
get_cache_enabled,
1415
get_health_payload,
16+
get_recent_logs,
1517
get_runtime_environment,
1618
get_runtime_log_level,
1719
get_runtime_port,
@@ -67,6 +69,12 @@ def _get_safe_database_config() -> dict[str, str | bool]:
6769
}
6870

6971

72+
def _has_dev_logs_access() -> bool:
73+
expected_token = os.getenv("DEV_LOGS_TOKEN", "")
74+
provided_token = request.headers.get("X-Dev-Token", "")
75+
return bool(expected_token) and provided_token == expected_token
76+
77+
7078
@general_ns.route("/hello")
7179
class HelloWorld(Resource):
7280
"""
@@ -154,3 +162,15 @@ class Health(Resource):
154162
def get(self):
155163
payload, status = get_health_payload()
156164
return payload, status
165+
166+
167+
@general_ns.route("/dev/logs")
168+
class DevLogs(Resource):
169+
"""Return recent application logs for developers only."""
170+
171+
def get(self):
172+
if not _has_dev_logs_access():
173+
return {"message": "forbidden"}, HTTPStatus.FORBIDDEN
174+
175+
limit = request.args.get("limit", default=100, type=int)
176+
return {"logs": get_recent_logs(limit)}, HTTPStatus.OK

server/tests/test_dev_logs.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import logging
2+
3+
from server.app import create_app
4+
5+
6+
def test_dev_logs_requires_token(monkeypatch):
7+
monkeypatch.setenv('DEV_LOGS_TOKEN', 'secret-token')
8+
app = create_app()
9+
10+
with app.test_client() as client:
11+
response = client.get('/dev/logs')
12+
13+
assert response.status_code == 403
14+
15+
16+
def test_dev_logs_returns_recent_entries(monkeypatch):
17+
monkeypatch.setenv('DEV_LOGS_TOKEN', 'secret-token')
18+
app = create_app()
19+
logging.getLogger('server.tests.dev_logs').warning('developer log endpoint smoke test')
20+
21+
with app.test_client() as client:
22+
response = client.get('/dev/logs?limit=5', headers={'X-Dev-Token': 'secret-token'})
23+
24+
payload = response.get_json()
25+
26+
assert response.status_code == 200
27+
assert payload['logs']
28+
assert any(entry['message'] == 'developer log endpoint smoke test' for entry in payload['logs'])
29+
assert len(payload['logs']) <= 5

0 commit comments

Comments
 (0)