Skip to content

Commit 730573a

Browse files
authored
qontract-api: sentry/glitchtip support (#5404)
* qontract-api: sentry/glitchtip support * tests * openshift deployment config
1 parent e725913 commit 730573a

File tree

6 files changed

+199
-4
lines changed

6 files changed

+199
-4
lines changed

openshift/qontract-api.yaml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ objects:
9595
value: api
9696
- name: QAPI_APP_PORT
9797
value: "${QAPI_APP_PORT}"
98+
- name: QAPI_SENTRY_DSN
99+
valueFrom:
100+
secretKeyRef:
101+
name: ${QAPI_SENTRY_SECRET_NAME}
102+
key: dsn
98103
envFrom:
99104
- secretRef:
100105
name: qontract-api-secret
@@ -227,6 +232,11 @@ objects:
227232
value: "${QAPI_WORKER_METRICS_PORT}"
228233
- name: QAPI_WORKER_TEMP_DIR
229234
value: /worker-temp-dir
235+
- name: QAPI_SENTRY_DSN
236+
valueFrom:
237+
secretKeyRef:
238+
name: ${QAPI_SENTRY_SECRET_NAME}
239+
key: dsn
230240
envFrom:
231241
- secretRef:
232242
name: qontract-api-secret
@@ -329,6 +339,11 @@ parameters:
329339
value: "INFO"
330340
required: true
331341

342+
- name: QAPI_SENTRY_SECRET_NAME
343+
description: Name of the secret containing the Sentry DSN
344+
value: "app-interface-prod-dsn"
345+
required: true
346+
332347
## Api Pod limits
333348
- name: QAPI_API_REPLICAS
334349
description: Web replicas

qontract_api/pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"python-jose[cryptography]==3.5.0",
1818
"qontract-utils",
1919
"redis==7.1.0",
20+
"sentry-sdk==2.51.0",
2021
"structlog==25.5.0",
2122
"uvicorn[standard]==0.38.0",
2223
]

qontract_api/qontract_api/config.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from typing import Any
44

5-
from pydantic import BaseModel, Field
5+
from pydantic import BaseModel, Field, field_validator
66
from pydantic_settings import (
77
BaseSettings,
88
JsonConfigSettingsSource,
@@ -269,6 +269,15 @@ def settings_customise_sources(
269269
default="slack_sdk,httpcore,github.Requester",
270270
description="Comma-separated list of logger names to exclude from logging",
271271
)
272+
# Sentry/Glitchtip
273+
sentry_dsn: str = Field(
274+
default="",
275+
description="Sentry DSN for error tracking (Glitchtip compatible)",
276+
)
277+
sentry_event_level: str = Field(
278+
default="ERROR",
279+
description="Minimum log level to send events to Sentry",
280+
)
272281

273282
# Cache Backend
274283
cache_backend: str = Field(
@@ -347,6 +356,18 @@ def settings_customise_sources(
347356
description="Secret backend configuration (Vault, AWS KMS, Google)",
348357
)
349358

359+
@field_validator("sentry_event_level", mode="after")
360+
@classmethod
361+
def validate_sentry_event_level(cls, value: str) -> str:
362+
"""Validate sentry_event_level is a valid logging level."""
363+
valid_levels = {"ERROR", "CRITICAL"}
364+
value = value.upper()
365+
if value not in valid_levels:
366+
raise ValueError(
367+
f"sentry_event_level must be one of {valid_levels}, got {value}"
368+
)
369+
return value
370+
350371
def get_celery_broker_url(self) -> str:
351372
"""Get Celery broker URL, defaulting to cache_broker_url."""
352373
return self.celery_broker_url or self.cache_broker_url

qontract_api/qontract_api/logger.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
import logging
1111

12+
import sentry_sdk
1213
import structlog
14+
from sentry_sdk.integrations.logging import LoggingIntegration
1315

1416
from qontract_api.config import settings
1517

@@ -93,6 +95,32 @@ def setup_logging() -> structlog.typing.WrappedLogger:
9395
excluded_logger = logging.getLogger(logger_name)
9496
excluded_logger.setLevel(logging.WARNING)
9597

98+
# initialize Sentry/Glitchtip integration if DSN is provided
99+
if settings.sentry_dsn:
100+
match settings.sentry_event_level:
101+
case "CRITICAL":
102+
sentry_event_level = logging.CRITICAL
103+
case "ERROR":
104+
sentry_event_level = logging.ERROR
105+
case _:
106+
raise NotImplementedError(
107+
f"Unsupported sentry_event_level: {settings.sentry_event_level}"
108+
)
109+
110+
sentry_sdk.init(
111+
dsn=settings.sentry_dsn,
112+
# Add data like request headers and IP for users, if applicable;
113+
# see https://docs.sentry.io/platforms/python/data-management/data-collected/ for more info
114+
# Sensitive headers are automatically filtered by Sentry.
115+
send_default_pii=True,
116+
# Enable logs to be sent to Sentry
117+
enable_logs=True,
118+
ignore_errors=[ConnectionError],
119+
integrations=[
120+
LoggingIntegration(event_level=sentry_event_level),
121+
],
122+
)
123+
96124
return structlog.get_logger("qontract_api")
97125

98126

qontract_api/tests/test_logger.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from io import StringIO
66
from unittest.mock import patch
77

8+
import pytest
89
import structlog
910
from structlog.typing import Processor
1011

@@ -26,6 +27,7 @@ def test_setup_logging_returns_qontract_api_logger() -> None:
2627
mock_settings.log_level = "INFO"
2728
mock_settings.log_format_json = True
2829
mock_settings.log_exclude_loggers = ""
30+
mock_settings.sentry_dsn = ""
2931

3032
logger = setup_logging()
3133

@@ -41,6 +43,7 @@ def test_setup_logging_with_json_format() -> None:
4143
mock_settings.log_level = "INFO"
4244
mock_settings.log_format_json = True
4345
mock_settings.log_exclude_loggers = ""
46+
mock_settings.sentry_dsn = ""
4447

4548
# Clear existing handlers
4649
root_logger = logging.getLogger()
@@ -61,6 +64,7 @@ def test_setup_logging_with_standard_format() -> None:
6164
mock_settings.log_level = "INFO"
6265
mock_settings.log_format_json = False
6366
mock_settings.log_exclude_loggers = ""
67+
mock_settings.sentry_dsn = ""
6468

6569
# Clear existing handlers
6670
root_logger = logging.getLogger()
@@ -81,6 +85,7 @@ def test_logger_with_json_output() -> None:
8185
mock_settings.log_level = "INFO"
8286
mock_settings.log_format_json = True
8387
mock_settings.log_exclude_loggers = ""
88+
mock_settings.sentry_dsn = ""
8489

8590
# Setup logging with JSON format
8691
root_logger = logging.getLogger()
@@ -141,6 +146,7 @@ def test_logger_with_context_vars() -> None:
141146
mock_settings.log_level = "INFO"
142147
mock_settings.log_format_json = True
143148
mock_settings.log_exclude_loggers = ""
149+
mock_settings.sentry_dsn = ""
144150

145151
# Setup logging with JSON format
146152
root_logger = logging.getLogger()
@@ -200,6 +206,7 @@ def test_logger_with_extra_fields() -> None:
200206
mock_settings.log_level = "INFO"
201207
mock_settings.log_format_json = True
202208
mock_settings.log_exclude_loggers = ""
209+
mock_settings.sentry_dsn = ""
203210

204211
# Setup logging
205212
root_logger = logging.getLogger()
@@ -267,6 +274,7 @@ def test_logger_with_exception() -> None:
267274
mock_settings.log_level = "INFO"
268275
mock_settings.log_format_json = True
269276
mock_settings.log_exclude_loggers = ""
277+
mock_settings.sentry_dsn = ""
270278

271279
# Setup logging
272280
root_logger = logging.getLogger()
@@ -324,3 +332,123 @@ def test_logger_with_exception() -> None:
324332
assert exc_info["exc_value"] == "Test error"
325333

326334
root_logger.handlers.clear()
335+
336+
337+
def test_sentry_init_called_when_dsn_provided() -> None:
338+
"""Test that sentry_sdk.init is called when SENTRY_DSN is provided."""
339+
with (
340+
patch("qontract_api.logger.settings") as mock_settings,
341+
patch("qontract_api.logger.sentry_sdk.init") as mock_sentry_init,
342+
):
343+
mock_settings.log_level = "INFO"
344+
mock_settings.log_format_json = True
345+
mock_settings.log_exclude_loggers = ""
346+
mock_settings.sentry_dsn = "https://example@sentry.io/123"
347+
mock_settings.sentry_event_level = "ERROR"
348+
349+
root_logger = logging.getLogger()
350+
root_logger.handlers.clear()
351+
352+
setup_logging()
353+
354+
mock_sentry_init.assert_called_once()
355+
call_args = mock_sentry_init.call_args
356+
357+
assert call_args.kwargs["dsn"] == "https://example@sentry.io/123"
358+
assert call_args.kwargs["send_default_pii"] is True
359+
assert call_args.kwargs["enable_logs"] is True
360+
assert call_args.kwargs["ignore_errors"] == [ConnectionError]
361+
362+
root_logger.handlers.clear()
363+
364+
365+
def test_sentry_init_not_called_when_dsn_empty() -> None:
366+
"""Test that sentry_sdk.init is not called when SENTRY_DSN is empty."""
367+
with (
368+
patch("qontract_api.logger.settings") as mock_settings,
369+
patch("qontract_api.logger.sentry_sdk.init") as mock_sentry_init,
370+
):
371+
mock_settings.log_level = "INFO"
372+
mock_settings.log_format_json = True
373+
mock_settings.log_exclude_loggers = ""
374+
mock_settings.sentry_dsn = ""
375+
376+
root_logger = logging.getLogger()
377+
root_logger.handlers.clear()
378+
379+
setup_logging()
380+
381+
mock_sentry_init.assert_not_called()
382+
383+
root_logger.handlers.clear()
384+
385+
386+
def test_sentry_init_with_error_level() -> None:
387+
"""Test sentry_sdk.init uses ERROR event level correctly."""
388+
with (
389+
patch("qontract_api.logger.settings") as mock_settings,
390+
patch("qontract_api.logger.sentry_sdk.init") as mock_sentry_init,
391+
patch("qontract_api.logger.LoggingIntegration") as mock_logging_integration,
392+
):
393+
mock_settings.log_level = "INFO"
394+
mock_settings.log_format_json = True
395+
mock_settings.log_exclude_loggers = ""
396+
mock_settings.sentry_dsn = "https://example@sentry.io/123"
397+
mock_settings.sentry_event_level = "ERROR"
398+
399+
root_logger = logging.getLogger()
400+
root_logger.handlers.clear()
401+
402+
setup_logging()
403+
404+
mock_sentry_init.assert_called_once()
405+
mock_logging_integration.assert_called_once_with(event_level=logging.ERROR)
406+
407+
root_logger.handlers.clear()
408+
409+
410+
def test_sentry_init_with_critical_level() -> None:
411+
"""Test sentry_sdk.init uses CRITICAL event level correctly."""
412+
with (
413+
patch("qontract_api.logger.settings") as mock_settings,
414+
patch("qontract_api.logger.sentry_sdk.init") as mock_sentry_init,
415+
patch("qontract_api.logger.LoggingIntegration") as mock_logging_integration,
416+
):
417+
mock_settings.log_level = "INFO"
418+
mock_settings.log_format_json = True
419+
mock_settings.log_exclude_loggers = ""
420+
mock_settings.sentry_dsn = "https://example@sentry.io/123"
421+
mock_settings.sentry_event_level = "CRITICAL"
422+
423+
root_logger = logging.getLogger()
424+
root_logger.handlers.clear()
425+
426+
setup_logging()
427+
428+
mock_sentry_init.assert_called_once()
429+
mock_logging_integration.assert_called_once_with(event_level=logging.CRITICAL)
430+
431+
root_logger.handlers.clear()
432+
433+
434+
def test_sentry_init_with_invalid_level_raises_error() -> None:
435+
"""Test that invalid SENTRY_EVENT_LEVEL raises NotImplementedError."""
436+
with (
437+
patch("qontract_api.logger.settings") as mock_settings,
438+
patch("qontract_api.logger.sentry_sdk.init"),
439+
):
440+
mock_settings.log_level = "INFO"
441+
mock_settings.log_format_json = True
442+
mock_settings.log_exclude_loggers = ""
443+
mock_settings.sentry_dsn = "https://example@sentry.io/123"
444+
mock_settings.sentry_event_level = "WARNING"
445+
446+
root_logger = logging.getLogger()
447+
root_logger.handlers.clear()
448+
449+
with pytest.raises(
450+
NotImplementedError, match="Unsupported sentry_event_level: WARNING"
451+
):
452+
setup_logging()
453+
454+
root_logger.handlers.clear()

uv.lock

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)