Files
TimeTracker/tests/test_otel_integration.py
T
Dries Peeters 4007ee2ca8 feat(observability): add OpenTelemetry traces, OTLP metrics, and log correlation
Introduce a centralized OTel layer (app/telemetry/otel_setup.py) that reuses
the existing OTLP endpoint and token, exports traces and metrics over OTLP/HTTP,
and instruments Flask plus SQLAlchemy. Manual OTLP log export remains for base
and product analytics; log records now include trace_id, span_id, and
event_category where tracing is active.

Business spans and product metrics cover invoices, timers, reports, auth,
webhook delivery, and scheduled jobs. RED-style HTTP metrics are recorded in
after_request alongside existing Prometheus counters. ENABLE_TRACING and
ENABLE_METRICS default on when credentials exist; graceful no-op when they do not.

Privacy is preserved: user_id appears on traces only when detailed analytics is
opted in; metrics never carry user_id; _remove_pii behavior for analytics is
unchanged. Responses inject traceparent when tracing is enabled for future
browser correlation.

Tests: test_otel_integration.py and per-test reset_for_testing() in conftest
so each app factory can reinitialize OTel.
2026-03-28 17:32:18 +01:00

78 lines
2.5 KiB
Python

"""OpenTelemetry tracing, metrics hooks, and OTLP log correlation tests."""
import uuid
from unittest.mock import patch
import pytest
@pytest.fixture
def otel_app(app_config, monkeypatch, tmp_path):
"""Flask app with in-memory OTel export (no network)."""
monkeypatch.setenv("OTEL_ENABLE_IN_TESTS", "1")
from app.telemetry.otel_setup import reset_for_testing
reset_for_testing()
unique_db_path = tmp_path / f"otel_{uuid.uuid4().hex}.sqlite"
config = dict(app_config)
config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{unique_db_path}"
from app import create_app, db
application = create_app(config)
with application.app_context():
import app.models # noqa: F401 — register metadata
db.create_all()
return application
@pytest.fixture
def otel_client(otel_app):
return otel_app.test_client()
def test_health_request_emits_span(otel_client):
resp = otel_client.get("/_health")
assert resp.status_code == 200
from app.telemetry.otel_setup import get_test_span_exporter
exp = get_test_span_exporter()
assert exp is not None
spans = exp.get_finished_spans()
assert len(spans) >= 1
def test_otlp_log_payload_has_trace_and_event_category():
from app.telemetry.service import _build_otlp_logs_payload
fake_tid = "a" * 32
fake_sid = "b" * 16
with patch("app.telemetry.otel_setup.is_otel_tracing_active", return_value=True):
with patch(
"app.telemetry.otel_setup.get_trace_context_for_logs",
return_value={"trace_id": fake_tid, "span_id": fake_sid},
):
payload = _build_otlp_logs_payload("auth.login", "1", True, {}, "1.0.0")
rec = payload["resourceLogs"][0]["scopeLogs"][0]["logRecords"][0]["attributes"]
keys_to_val = {a["key"]: a["value"] for a in rec}
assert keys_to_val.get("event_category") == {"stringValue": "auth"}
assert keys_to_val.get("trace_id") == {"stringValue": fake_tid}
assert keys_to_val.get("span_id") == {"stringValue": fake_sid}
def test_record_background_job_noop_without_metrics():
from app.telemetry.otel_setup import record_background_job_outcome, reset_for_testing
reset_for_testing()
record_background_job_outcome("check_overdue_invoices", True)
def test_http_server_metrics_record_does_not_raise_when_otel_inactive(otel_app):
from app.telemetry.otel_setup import record_http_server_metrics, reset_for_testing
reset_for_testing()
record_http_server_metrics("GET", "/_health", 200, 0.01)