Skip to content

Commit 0c9ae90

Browse files
committed
feat(sentry): add optional Sentry error tracking integration
Add Sentry error tracking that activates only when a SENTRY_DSN environment variable is set. Includes custom trace sampling to exclude health check, metrics, and root routes from tracing. - Add sentry-sdk[fastapi] runtime dependency - Add Sentry configuration constants to constants.py - Create src/sentry.py with initialize_sentry() and trace sampler - Wire Sentry init into FastAPI lifespan (startup + shutdown flush) - Add comprehensive unit tests covering all branches Signed-off-by: Major Hayden <major@redhat.com>
1 parent e59b4b9 commit 0c9ae90

6 files changed

Lines changed: 370 additions & 16 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ dependencies = [
7373
# To be able to fix multiple CVEs, also LCORE-1117
7474
"requests>=2.33.0",
7575
"datasets>=4.7.0",
76+
# Used for error tracking and monitoring
77+
"sentry-sdk[fastapi]>=2.58.0",
7678
]
7779

7880

src/app/main.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import AsyncIterator
55
from contextlib import asynccontextmanager
66

7+
import sentry_sdk # pyright: ignore[reportMissingImports]
78
from fastapi import FastAPI, HTTPException
89
from fastapi.middleware.cors import CORSMiddleware
910
from fastapi.responses import JSONResponse
@@ -22,6 +23,7 @@
2223
from configuration import configuration
2324
from log import get_logger
2425
from models.responses import InternalServerErrorResponse
26+
from sentry import initialize_sentry
2527
from utils.common import register_mcp_servers_async
2628
from utils.llama_stack_version import check_llama_stack_version
2729

@@ -44,6 +46,8 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
4446
"""
4547
configuration.load_configuration(os.environ["LIGHTSPEED_STACK_CONFIG_PATH"])
4648

49+
initialize_sentry()
50+
4751
azure_config = configuration.configuration.azure_entra_id
4852
if azure_config is not None:
4953
AzureEntraIDManager().set_config(azure_config)
@@ -81,8 +85,13 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
8185
yield
8286

8387
# Cleanup resources on shutdown
84-
await shutdown_background_topic_summary_tasks()
85-
await A2AStorageFactory.cleanup()
88+
try:
89+
await shutdown_background_topic_summary_tasks()
90+
await A2AStorageFactory.cleanup()
91+
finally:
92+
# Flush pending Sentry events after cleanup so any errors during
93+
# shutdown are captured before the process exits.
94+
sentry_sdk.flush(timeout=2)
8695
logger.info("App shutdown complete")
8796

8897

src/constants.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,3 +249,27 @@
249249
RLSAPI_V1_QUESTION_MAX_LENGTH: Final[int] = 32_768
250250
# Maximum character length for the serialized /v1/responses request body (64 KiB)
251251
RESPONSES_REQUEST_MAX_SIZE: Final[int] = 65_536
252+
253+
# Sentry configuration constants
254+
# Environment variable name for the Sentry DSN (Data Source Name)
255+
SENTRY_DSN_ENV_VAR: Final[str] = "SENTRY_DSN"
256+
# Environment variable name for the Sentry environment tag
257+
SENTRY_ENVIRONMENT_ENV_VAR: Final[str] = "SENTRY_ENVIRONMENT"
258+
# Default Sentry environment when SENTRY_ENVIRONMENT is not set
259+
SENTRY_DEFAULT_ENVIRONMENT: Final[str] = "development"
260+
# Default trace sample rate (fraction of transactions to capture)
261+
SENTRY_DEFAULT_TRACES_SAMPLE_RATE: Final[float] = 0.25
262+
# Routes excluded from Sentry trace sampling (health checks, metrics, root).
263+
# Note: health and metrics routers are mounted WITHOUT a /v1 prefix
264+
# (see the setup_routers function in src/app/routers.py), so ASGI paths are
265+
# /readiness, /liveness, /metrics.
266+
SENTRY_EXCLUDED_ROUTES: Final[tuple[str, ...]] = (
267+
"/readiness",
268+
"/liveness",
269+
"/metrics",
270+
"/",
271+
)
272+
# Environment variable name for the Sentry CA certificate bundle path.
273+
# Set this to a file path (e.g. /etc/pki/tls/certs/ca-bundle.crt) when
274+
# connecting to a Sentry instance that uses a private or internal CA.
275+
SENTRY_CA_CERTS_ENV_VAR: Final[str] = "SENTRY_CA_CERTS"

src/sentry.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Sentry error tracking initialization and configuration."""
2+
3+
import os
4+
5+
import sentry_sdk # pyright: ignore[reportMissingImports]
6+
from sentry_sdk.integrations.fastapi import ( # pyright: ignore[reportMissingImports]
7+
FastApiIntegration,
8+
)
9+
10+
import version
11+
from constants import (
12+
SENTRY_CA_CERTS_ENV_VAR,
13+
SENTRY_DEFAULT_ENVIRONMENT,
14+
SENTRY_DEFAULT_TRACES_SAMPLE_RATE,
15+
SENTRY_DSN_ENV_VAR,
16+
SENTRY_ENVIRONMENT_ENV_VAR,
17+
SENTRY_EXCLUDED_ROUTES,
18+
)
19+
from log import get_logger
20+
21+
logger = get_logger(__name__)
22+
23+
24+
def sentry_traces_sampler(tracing_context: dict) -> float:
25+
"""
26+
Determine the trace sample rate for a given request.
27+
28+
Excludes health check, metrics, and root routes from trace sampling to
29+
reduce noise. All other routes use the default sample rate.
30+
31+
Parameters:
32+
----------
33+
tracing_context (dict): The Sentry tracing context containing ASGI
34+
scope information, including the request path.
35+
36+
Returns:
37+
-------
38+
float: 0.0 for excluded routes (no sampling), or
39+
SENTRY_DEFAULT_TRACES_SAMPLE_RATE for all other routes.
40+
"""
41+
asgi_scope = tracing_context.get("asgi_scope", {})
42+
path = asgi_scope.get("path") if isinstance(asgi_scope, dict) else None
43+
44+
if path is not None:
45+
if path == "/":
46+
return 0.0
47+
if any(
48+
route != "/" and path.endswith(route) for route in SENTRY_EXCLUDED_ROUTES
49+
):
50+
return 0.0
51+
52+
return SENTRY_DEFAULT_TRACES_SAMPLE_RATE
53+
54+
55+
def initialize_sentry() -> None:
56+
"""
57+
Initialize Sentry error tracking if a DSN is configured.
58+
59+
Reads the SENTRY_DSN environment variable. If not set or empty, logs an
60+
informational message and returns without initializing Sentry. When a DSN
61+
is present, initializes the Sentry SDK with custom trace sampling, FastAPI
62+
integration, and optional CA certificate configuration.
63+
64+
When SENTRY_CA_CERTS is set to a file path, that certificate bundle is
65+
passed to the SDK for Sentry instances using private or internal CAs.
66+
67+
The DSN value is never logged to prevent accidental credential exposure.
68+
69+
Parameters:
70+
----------
71+
None
72+
73+
Returns:
74+
-------
75+
None
76+
"""
77+
dsn = os.environ.get(SENTRY_DSN_ENV_VAR)
78+
79+
if not dsn:
80+
logger.info("Sentry DSN not configured, skipping initialization")
81+
return
82+
83+
ca_certs = None
84+
ca_certs_path = os.environ.get(SENTRY_CA_CERTS_ENV_VAR)
85+
if ca_certs_path:
86+
if os.path.exists(ca_certs_path):
87+
ca_certs = ca_certs_path
88+
else:
89+
logger.warning(
90+
"CA cert file specified by %s not found at %s; "
91+
"proceeding without custom CA certs",
92+
SENTRY_CA_CERTS_ENV_VAR,
93+
ca_certs_path,
94+
)
95+
96+
environment = os.environ.get(SENTRY_ENVIRONMENT_ENV_VAR, SENTRY_DEFAULT_ENVIRONMENT)
97+
98+
try:
99+
sentry_sdk.init(
100+
dsn=dsn,
101+
environment=environment,
102+
traces_sampler=sentry_traces_sampler,
103+
send_default_pii=False,
104+
ca_certs=ca_certs,
105+
integrations=[FastApiIntegration(http_methods_to_capture=("POST",))],
106+
release=f"lightspeed-stack@{version.__version__}",
107+
)
108+
logger.info("Sentry initialized")
109+
except Exception: # pylint: disable=broad-exception-caught
110+
logger.exception(
111+
"Failed to initialize Sentry, continuing without error tracking"
112+
)

tests/unit/test_sentry.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Unit tests for functions defined in src/sentry.py."""
2+
3+
import pytest
4+
from pytest_mock import MockerFixture
5+
6+
from constants import (
7+
SENTRY_CA_CERTS_ENV_VAR,
8+
SENTRY_DEFAULT_ENVIRONMENT,
9+
SENTRY_DEFAULT_TRACES_SAMPLE_RATE,
10+
SENTRY_DSN_ENV_VAR,
11+
SENTRY_ENVIRONMENT_ENV_VAR,
12+
SENTRY_EXCLUDED_ROUTES,
13+
)
14+
from sentry import initialize_sentry, sentry_traces_sampler
15+
16+
17+
class TestInitializeSentry:
18+
"""Tests for the initialize_sentry function."""
19+
20+
def test_dsn_not_set(
21+
self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
22+
) -> None:
23+
"""Test that Sentry is not initialized when DSN env var is unset."""
24+
monkeypatch.delenv(SENTRY_DSN_ENV_VAR, raising=False)
25+
mock_init = mocker.patch("sentry.sentry_sdk.init")
26+
27+
initialize_sentry()
28+
29+
mock_init.assert_not_called()
30+
31+
def test_dsn_empty_string(
32+
self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
33+
) -> None:
34+
"""Test that Sentry is not initialized when DSN is an empty string."""
35+
monkeypatch.setenv(SENTRY_DSN_ENV_VAR, "")
36+
mock_init = mocker.patch("sentry.sentry_sdk.init")
37+
38+
initialize_sentry()
39+
40+
mock_init.assert_not_called()
41+
42+
def test_dsn_set_no_ca_certs(
43+
self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
44+
) -> None:
45+
"""Test Sentry init without CA certs env var uses ca_certs=None."""
46+
dsn = "https://key@sentry.io/123"
47+
monkeypatch.setenv(SENTRY_DSN_ENV_VAR, dsn)
48+
monkeypatch.delenv(SENTRY_ENVIRONMENT_ENV_VAR, raising=False)
49+
monkeypatch.delenv(SENTRY_CA_CERTS_ENV_VAR, raising=False)
50+
mock_init = mocker.patch("sentry.sentry_sdk.init")
51+
52+
initialize_sentry()
53+
54+
mock_init.assert_called_once()
55+
call_kwargs = mock_init.call_args.kwargs
56+
assert call_kwargs["dsn"] == dsn
57+
assert call_kwargs["ca_certs"] is None
58+
assert call_kwargs["send_default_pii"] is False
59+
assert call_kwargs["environment"] == SENTRY_DEFAULT_ENVIRONMENT
60+
assert call_kwargs["release"].startswith("lightspeed-stack@")
61+
62+
def test_ca_certs_file_exists(
63+
self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
64+
) -> None:
65+
"""Test that ca_certs is set when SENTRY_CA_CERTS points to an existing file."""
66+
ca_path = "/etc/pki/tls/certs/ca-bundle.crt"
67+
monkeypatch.setenv(SENTRY_DSN_ENV_VAR, "https://key@sentry.io/123")
68+
monkeypatch.setenv(SENTRY_CA_CERTS_ENV_VAR, ca_path)
69+
monkeypatch.delenv(SENTRY_ENVIRONMENT_ENV_VAR, raising=False)
70+
mock_init = mocker.patch("sentry.sentry_sdk.init")
71+
mocker.patch("sentry.os.path.exists", return_value=True)
72+
73+
initialize_sentry()
74+
75+
mock_init.assert_called_once()
76+
assert mock_init.call_args.kwargs["ca_certs"] == ca_path
77+
78+
def test_ca_certs_file_missing(
79+
self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
80+
) -> None:
81+
"""Test that ca_certs is None and a warning is logged when the cert file is missing."""
82+
ca_path = "/nonexistent/ca-bundle.crt"
83+
monkeypatch.setenv(SENTRY_DSN_ENV_VAR, "https://key@sentry.io/123")
84+
monkeypatch.setenv(SENTRY_CA_CERTS_ENV_VAR, ca_path)
85+
monkeypatch.delenv(SENTRY_ENVIRONMENT_ENV_VAR, raising=False)
86+
mock_init = mocker.patch("sentry.sentry_sdk.init")
87+
mocker.patch("sentry.os.path.exists", return_value=False)
88+
mock_logger = mocker.patch("sentry.logger")
89+
90+
initialize_sentry()
91+
92+
mock_init.assert_called_once()
93+
assert mock_init.call_args.kwargs["ca_certs"] is None
94+
mock_logger.warning.assert_called_once_with(
95+
"CA cert file specified by %s not found at %s; "
96+
"proceeding without custom CA certs",
97+
SENTRY_CA_CERTS_ENV_VAR,
98+
ca_path,
99+
)
100+
101+
def test_custom_environment(
102+
self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
103+
) -> None:
104+
"""Test that a custom SENTRY_ENVIRONMENT value is passed to init."""
105+
monkeypatch.setenv(SENTRY_DSN_ENV_VAR, "https://key@sentry.io/123")
106+
monkeypatch.setenv(SENTRY_ENVIRONMENT_ENV_VAR, "staging")
107+
mock_init = mocker.patch("sentry.sentry_sdk.init")
108+
109+
initialize_sentry()
110+
111+
mock_init.assert_called_once()
112+
assert mock_init.call_args.kwargs["environment"] == "staging"
113+
114+
def test_default_environment(
115+
self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
116+
) -> None:
117+
"""Test that default environment is used when env var is unset."""
118+
monkeypatch.setenv(SENTRY_DSN_ENV_VAR, "https://key@sentry.io/123")
119+
monkeypatch.delenv(SENTRY_ENVIRONMENT_ENV_VAR, raising=False)
120+
mock_init = mocker.patch("sentry.sentry_sdk.init")
121+
122+
initialize_sentry()
123+
124+
mock_init.assert_called_once()
125+
assert mock_init.call_args.kwargs["environment"] == SENTRY_DEFAULT_ENVIRONMENT
126+
127+
def test_init_failure_does_not_raise(
128+
self, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture
129+
) -> None:
130+
"""Test that a failure during sentry_sdk.init does not propagate."""
131+
monkeypatch.setenv(SENTRY_DSN_ENV_VAR, "https://key@sentry.io/123")
132+
monkeypatch.delenv(SENTRY_ENVIRONMENT_ENV_VAR, raising=False)
133+
monkeypatch.delenv(SENTRY_CA_CERTS_ENV_VAR, raising=False)
134+
mocker.patch(
135+
"sentry.sentry_sdk.init", side_effect=RuntimeError("connection failed")
136+
)
137+
mock_logger = mocker.patch("sentry.logger")
138+
139+
initialize_sentry()
140+
141+
mock_logger.exception.assert_called_once_with(
142+
"Failed to initialize Sentry, continuing without error tracking"
143+
)
144+
145+
146+
class TestSentryTracesSampler:
147+
"""Tests for the sentry_traces_sampler function."""
148+
149+
@pytest.mark.parametrize(
150+
"path",
151+
list(SENTRY_EXCLUDED_ROUTES),
152+
ids=[r.lstrip("/") or "root" for r in SENTRY_EXCLUDED_ROUTES],
153+
)
154+
def test_excluded_routes_return_zero(self, path: str) -> None:
155+
"""Test that excluded routes produce a sample rate of 0.0."""
156+
context: dict = {"asgi_scope": {"path": path}}
157+
assert sentry_traces_sampler(context) == 0.0
158+
159+
def test_excluded_route_suffix_match(self) -> None:
160+
"""Test that suffix matching works for excluded routes (e.g. /prometheus/metrics)."""
161+
context: dict = {"asgi_scope": {"path": "/prometheus/metrics"}}
162+
assert sentry_traces_sampler(context) == 0.0
163+
164+
@pytest.mark.parametrize(
165+
"path",
166+
["/v1/query", "/v1/feedback", "/v1/query/"],
167+
ids=["query", "feedback", "query_trailing_slash"],
168+
)
169+
def test_normal_routes_return_default_rate(self, path: str) -> None:
170+
"""Test that non-excluded routes use the default sample rate."""
171+
context: dict = {"asgi_scope": {"path": path}}
172+
assert sentry_traces_sampler(context) == SENTRY_DEFAULT_TRACES_SAMPLE_RATE
173+
174+
def test_empty_context(self) -> None:
175+
"""Test that an empty tracing context returns the default sample rate."""
176+
assert sentry_traces_sampler({}) == SENTRY_DEFAULT_TRACES_SAMPLE_RATE
177+
178+
def test_missing_path_in_asgi_scope(self) -> None:
179+
"""Test that missing path key in asgi_scope returns the default rate."""
180+
context: dict = {"asgi_scope": {}}
181+
assert sentry_traces_sampler(context) == SENTRY_DEFAULT_TRACES_SAMPLE_RATE
182+
183+
def test_none_path_value(self) -> None:
184+
"""Test that a None path value returns the default sample rate."""
185+
context: dict = {"asgi_scope": {"path": None}}
186+
assert sentry_traces_sampler(context) == SENTRY_DEFAULT_TRACES_SAMPLE_RATE

0 commit comments

Comments
 (0)