Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions .env.benefit-backend.example
Original file line number Diff line number Diff line change
Expand Up @@ -88,16 +88,15 @@ DEFAULT_FROM_EMAIL='Helsinki-lisä <helsinkilisa@hel.fi>'

# Sentry configuration for scrubbing sensitive data
# Values set here are for local development, no data is scrubbed.
ENTRY_ATTACH_STACKTRACE=True
SENTRY_ATTACH_STACKTRACE=True
SENTRY_MAX_BREADCRUMBS=100
SENTRY_REQUEST_BODIES=always
SENTRY_SEND_DEFAULT_PII=True
SENTRY_WITH_LOCALS=True

# For connecting to Sentry, get the correct DSN from Sentry or from a team member
SENTRY_DSN=
# The environment that the application is shown under in Sentry
# local / development / testing
SENTRY_PROFILE_SESSION_SAMPLE_RATE=0
SENTRY_TRACES_SAMPLE_RATE=0
SENTRY_TRACES_IGNORE_PATHS=/healthz,/readiness
SENTRY_ENVIRONMENT=local

# for Mailpit inbox
Expand All @@ -112,8 +111,8 @@ AHJO_REDIRECT_URL=
DISABLE_AHJO_SAFE_LIST_CHECK=True

AHJO_TEST_USER_FIRST_NAME=
AHJO_TEST_USER_LAST_NAME=
AHJO_TEST_USER_AD_USERNAME=
AHJO_TEST_USER_LAST_NAME=
AHJO_TEST_USER_AD_USERNAME=

ENABLE_CLAMAV=1
CLAMAV_URL=http://localhost:8080/api/v1
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,21 +13,21 @@ repos:
- id: check-toml
- id: check-added-large-files
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.1
rev: v0.14.6
hooks:
# Run the linter.
- id: ruff-check
args: [ "--fix" ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.22.0
rev: v9.23.0
hooks:
- id: commitlint
stages: [commit-msg, manual]
additional_dependencies: ["@commitlint/config-conventional"]
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.10.0
rev: v0.11.0
hooks:
- id: shellcheck
- repo: local
Expand Down
19 changes: 18 additions & 1 deletion backend/benefit/.prod/uwsgi.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ module = helsinkibenefit.wsgi
static-map = /static=/var/static
uid = nobody
gid = nogroup
enable-threads = true
py-call-uwsgi-fork-hooks = true
buffer-size = 32768
master = 1
processes = 2
threads = 2
thunder-lock = true
; don't log readiness and healthz endpoints
route = ^/readiness$ donotlog:
route = ^/healthz$ donotlog:
Expand All @@ -23,4 +26,18 @@ log-req-encoder = nl
# http pipes are closed before workers has had the time to serve content to the pipe
ignore-sigpipe = true
ignore-write-errors = true
disable-write-exception = true
disable-write-exception = true

plugin-dir = /usr/local/lib/uwsgi/plugins

# Sentry logging for uWSGI
if-env = ENABLE_SENTRY_UWSGI_PLUGIN
print = Enabled sentry logging for uWSGI
plugin = sentry
alarm = logsentry sentry:dsn=$(SENTRY_DSN),logger=uwsgi.sentry

# Log full queue, segfault and harakiri errors to Sentry
alarm-backlog = logsentry
alarm-segfault = logsentry
alarm-log = logsentry HARAKIRI \[core.*\]
endif =
55 changes: 42 additions & 13 deletions backend/benefit/helsinkibenefit/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from corsheaders.defaults import default_headers
from django.utils.translation import gettext_lazy as _
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.types import SamplingContext

from shared.service_bus.enums import YtjOrganizationCode

Expand Down Expand Up @@ -39,13 +40,16 @@
MAIL_MAILGUN_DOMAIN=(str, ""),
MAIL_MAILGUN_API=(str, ""),
SENTRY_DSN=(str, ""),
SENTRY_ENVIRONMENT=(str, ""),
SENTRY_ENVIRONMENT=(str, "local"),
SENTRY_PROFILE_SESSION_SAMPLE_RATE=(float, None),
SENTRY_RELEASE=(str, None),
SENTRY_TRACES_SAMPLE_RATE=(float, None),
SENTRY_TRACES_IGNORE_PATHS=(list, ["/healthz", "/readiness"]),
SENTRY_ATTACH_STACKTRACE=(bool, False),
SENTRY_MAX_BREADCRUMBS=(int, 0),
SENTRY_MAX_REQUEST_BODY_SIZE=(str, "never"),
SENTRY_SEND_DEFAULT_PII=(bool, False),
SENTRY_INCLUDE_LOCAL_VARIABLES=(bool, False),
SENTRY_RELEASE=(str, ""),
CORS_ALLOWED_ORIGINS=(list, []),
CORS_ALLOW_ALL_ORIGINS=(bool, False),
CSRF_COOKIE_DOMAIN=(str, "localhost"),
Expand Down Expand Up @@ -222,17 +226,42 @@

CACHES = {"default": env.cache()}

sentry_sdk.init(
attach_stacktrace=env.bool("SENTRY_ATTACH_STACKTRACE"),
max_breadcrumbs=env.int("SENTRY_MAX_BREADCRUMBS"),
max_request_body_size=env.str("SENTRY_MAX_REQUEST_BODY_SIZE"),
send_default_pii=env.bool("SENTRY_SEND_DEFAULT_PII"),
include_local_variables=env.bool("SENTRY_INCLUDE_LOCAL_VARIABLES"),
dsn=env.str("SENTRY_DSN"),
release=env.str("SENTRY_RELEASE"),
environment=env.str("SENTRY_ENVIRONMENT"),
integrations=[DjangoIntegration()],
)
# Sentry configuration
SENTRY_TRACES_SAMPLE_RATE = env("SENTRY_TRACES_SAMPLE_RATE")
SENTRY_TRACES_IGNORE_PATHS = env.list("SENTRY_TRACES_IGNORE_PATHS")


def sentry_traces_sampler(sampling_context: SamplingContext) -> float:
# Respect parent sampling decision if one exists. Recommended by Sentry.
if (parent_sampled := sampling_context.get("parent_sampled")) is not None:
return float(parent_sampled)

# Exclude health check endpoints from tracing
path = sampling_context.get("wsgi_environ", {}).get("PATH_INFO", "")
if path.rstrip("/") in SENTRY_TRACES_IGNORE_PATHS:
return 0

# Use configured sample rate for all other requests
return SENTRY_TRACES_SAMPLE_RATE or 0


SENTRY_DSN = env("SENTRY_DSN")

if SENTRY_DSN:
sentry_sdk.init(
attach_stacktrace=env.bool("SENTRY_ATTACH_STACKTRACE"),
max_breadcrumbs=env.int("SENTRY_MAX_BREADCRUMBS"),
max_request_body_size=env.str("SENTRY_MAX_REQUEST_BODY_SIZE"),
send_default_pii=env.bool("SENTRY_SEND_DEFAULT_PII"),
include_local_variables=env.bool("SENTRY_INCLUDE_LOCAL_VARIABLES"),
dsn=SENTRY_DSN,
environment=env("SENTRY_ENVIRONMENT"),
release=env("SENTRY_RELEASE"),
integrations=[DjangoIntegration()],
traces_sampler=sentry_traces_sampler,
profile_session_sample_rate=env("SENTRY_PROFILE_SESSION_SAMPLE_RATE"),
profile_lifecycle="trace",
)

MEDIA_ROOT = env("MEDIA_ROOT")
STATIC_ROOT = env("STATIC_ROOT")
Expand Down
107 changes: 107 additions & 0 deletions backend/benefit/helsinkibenefit/tests/test_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import os
from unittest.mock import patch


class TestSentryConfiguration:
"""Tests for Sentry configuration and traces sampler."""

def test_sentry_traces_sampler_with_parent_sampled_true(self):
"""Test that parent sampling decision is respected when True."""
from helsinkibenefit.settings import sentry_traces_sampler

sampling_context = {"parent_sampled": True}
result = sentry_traces_sampler(sampling_context)

assert str(result) == str(1.0)

def test_sentry_traces_sampler_with_parent_sampled_false(self):
"""Test that parent sampling decision is respected when False."""
from helsinkibenefit.settings import sentry_traces_sampler

sampling_context = {"parent_sampled": False}
result = sentry_traces_sampler(sampling_context)

assert str(result) == str(0.0)

@patch(
"helsinkibenefit.settings.SENTRY_TRACES_IGNORE_PATHS",
["/healthz", "/readiness"],
)
def test_sentry_traces_sampler_ignores_health_check_paths(self):
"""Test that health check endpoints return 0 sample rate."""
from helsinkibenefit.settings import sentry_traces_sampler

sampling_context = {
"parent_sampled": None,
"wsgi_environ": {"PATH_INFO": "/healthz/"},
}
result = sentry_traces_sampler(sampling_context)

assert result == 0

@patch("helsinkibenefit.settings.SENTRY_TRACES_SAMPLE_RATE", 0.5)
def test_sentry_traces_sampler_uses_configured_rate(self):
"""Test that configured sample rate is used for normal requests."""
from helsinkibenefit.settings import sentry_traces_sampler

sampling_context = {
"parent_sampled": None,
"wsgi_environ": {"PATH_INFO": "/api/v1/applications/"},
}
result = sentry_traces_sampler(sampling_context)

assert str(result) == str(0.5)

@patch("helsinkibenefit.settings.SENTRY_TRACES_SAMPLE_RATE", None)
def test_sentry_traces_sampler_defaults_to_zero(self):
"""Test that sample rate defaults to 0 when not configured."""
from helsinkibenefit.settings import sentry_traces_sampler

sampling_context = {
"parent_sampled": None,
"wsgi_environ": {"PATH_INFO": "/api/v1/applications/"},
}
result = sentry_traces_sampler(sampling_context)

assert result == 0

@patch.dict(
os.environ, {"SENTRY_DSN": "https://example@sentry.io/123456"}, clear=False
)
@patch("sentry_sdk.init")
def test_sentry_init_called_with_dsn(self, mock_sentry_init):
"""Test that Sentry is initialized when DSN is configured."""
import sys

# Remove the module from cache to force reimport
if "helsinkibenefit.settings" in sys.modules:
del sys.modules["helsinkibenefit.settings"]

# Import will trigger the module-level sentry_sdk.init call
import helsinkibenefit.settings # noqa: F401

# Verify sentry_sdk.init was called
assert mock_sentry_init.called
# Optionally verify it was called with correct DSN
assert any(
"https://example@sentry.io/123456" in str(call_args)
for call_args in mock_sentry_init.call_args_list
)

@patch.dict(os.environ, {"SENTRY_DSN": ""}, clear=False)
@patch("sentry_sdk.init")
def test_sentry_init_not_called_without_dsn(self, mock_sentry_init):
"""Test that Sentry is not initialized when DSN is empty."""
import sys

# Remove the module from cache to force reimport
if "helsinkibenefit.settings" in sys.modules:
del sys.modules["helsinkibenefit.settings"]

# Reset the mock to clear any previous calls
mock_sentry_init.reset_mock()

# Import with empty DSN should not call sentry_sdk.init
import helsinkibenefit.settings # noqa: F401

assert not mock_sentry_init.called
3 changes: 2 additions & 1 deletion backend/benefit/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ extend-per-file-ignores = { "*/migrations/*" = ["E501"], "*/tests/*" = ["E501"]
[tool.ruff.lint.isort]
known-first-party = ["shared"]

[tool.pip-tools]
[tool.pip-tools.compile]
strip-extras = true
allow-unsafe = true
32 changes: 17 additions & 15 deletions backend/benefit/requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,49 +2,49 @@
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --strip-extras requirements-dev.in
# pip-compile --allow-unsafe --strip-extras requirements-dev.in
#
asttokens==3.0.0
asttokens==3.0.1
# via stack-data
build==1.3.0
# via pip-tools
certifi==2025.8.3
certifi==2025.11.12
# via
# -c requirements.txt
# requests
charset-normalizer==3.4.3
charset-normalizer==3.4.4
# via
# -c requirements.txt
# requests
click==8.3.0
click==8.3.1
# via pip-tools
coverage==7.10.7
coverage==7.12.0
# via pytest-cov
decorator==5.2.1
# via ipython
executing==2.2.1
# via stack-data
freezegun==1.5.5
# via pytest-freezer
idna==3.10
idna==3.11
# via
# -c requirements.txt
# requests
iniconfig==2.1.0
iniconfig==2.3.0
# via pytest
ipython==9.6.0
ipython==9.7.0
# via -r requirements-dev.in
ipython-pygments-lexers==1.1.1
# via ipython
jedi==0.19.2
# via ipython
langdetect==1.0.9
# via -r requirements-dev.in
markupsafe==3.0.2
markupsafe==3.0.3
# via
# -c requirements.txt
# werkzeug
matplotlib-inline==0.1.7
matplotlib-inline==0.2.1
# via ipython
packaging==25.0
# via
Expand All @@ -55,7 +55,7 @@ parso==0.8.5
# via jedi
pexpect==4.9.0
# via ipython
pip-tools==7.5.1
pip-tools==7.5.2
# via -r requirements-dev.in
pluggy==1.6.0
# via
Expand All @@ -76,7 +76,7 @@ pyproject-hooks==1.2.0
# via
# build
# pip-tools
pytest==8.4.2
pytest==9.0.1
# via
# -r requirements-dev.in
# pytest-cov
Expand Down Expand Up @@ -121,5 +121,7 @@ wheel==0.45.1
# via pip-tools

# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools
pip==25.3
# via pip-tools
setuptools==80.9.0
# via pip-tools
4 changes: 2 additions & 2 deletions backend/benefit/requirements-prod.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# This file is autogenerated by pip-compile with Python 3.12
# by the following command:
#
# pip-compile --strip-extras requirements-prod.in
# pip-compile --allow-unsafe --strip-extras requirements-prod.in
#
uwsgi==2.0.30
uwsgi==2.0.31
# via -r requirements-prod.in
2 changes: 1 addition & 1 deletion backend/benefit/requirements.in
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,5 @@ python-stdnum>=1.19
python-Levenshtein
pyyaml
requests
sentry-sdk
sentry-sdk[django]
uritemplate
Loading
Loading