Skip to content

Commit 8b25228

Browse files
committed
Use tunnel for sentry
1 parent f8b4fe2 commit 8b25228

8 files changed

Lines changed: 178 additions & 36 deletions

File tree

astra_app/core/context_processors.py

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from email.utils import parseaddr
2-
from urllib.parse import urlsplit
32

43
from django.conf import settings
4+
from django.contrib.staticfiles.storage import staticfiles_storage
5+
from django.urls import reverse
56

67
from core.build_info import get_build_sha
78
from core.membership import get_membership_review_badge_counts
@@ -43,33 +44,23 @@ def chat_networks(_request) -> dict[str, object]:
4344
return {"chat_networks": settings.CHAT_NETWORKS}
4445

4546

46-
def _sentry_browser_loader_src(dsn: str) -> str:
47-
parsed = urlsplit(dsn)
48-
public_key = parsed.username or ""
49-
if not public_key:
50-
return ""
51-
52-
# Use Sentry's loader script because this repo does not have a JS package build.
53-
return f"https://js.sentry-cdn.com/{public_key}.min.js"
54-
55-
5647
def build_info(_request) -> dict[str, object]:
57-
sentry_browser_loader_src = ""
48+
sentry_browser_bundle_src = ""
5849
sentry_browser_config: dict[str, object] | None = None
5950
if settings.SENTRY_DSN:
60-
sentry_browser_loader_src = _sentry_browser_loader_src(settings.SENTRY_DSN)
61-
if sentry_browser_loader_src:
62-
sentry_browser_config = {
63-
"dsn": settings.SENTRY_DSN,
64-
"environment": settings.SENTRY_ENVIRONMENT,
65-
"release": settings.SENTRY_RELEASE,
66-
"tracesSampleRate": settings.SENTRY_TRACES_SAMPLE_RATE,
67-
}
51+
sentry_browser_bundle_src = staticfiles_storage.url("core/vendor/sentry/bundle.tracing.min.js")
52+
sentry_browser_config = {
53+
"dsn": settings.SENTRY_DSN,
54+
"environment": settings.SENTRY_ENVIRONMENT,
55+
"release": settings.SENTRY_RELEASE,
56+
"tracesSampleRate": settings.SENTRY_TRACES_SAMPLE_RATE,
57+
"tunnel": reverse("sentry-browser-tunnel"),
58+
}
6859

6960
return {
7061
"build_sha": get_build_sha(),
7162
"default_from_email_address": parseaddr(settings.DEFAULT_FROM_EMAIL)[1].strip(),
72-
"sentry_browser_loader_src": sentry_browser_loader_src,
63+
"sentry_browser_bundle_src": sentry_browser_bundle_src,
7364
"sentry_browser_config": sentry_browser_config,
7465
}
7566

astra_app/core/middleware.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,7 @@ def __init__(self, get_response):
435435
self._allowed_prefixes: tuple[str, ...] = (
436436
settings.STATIC_URL,
437437
settings.MEDIA_URL,
438+
"/_ci/",
438439
"/register",
439440
"/password-reset",
440441
"/admin/",

astra_app/core/static/core/vendor/sentry/bundle.tracing.min.js

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

astra_app/core/templates/core/base.html

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,27 @@
1818
{% if not request.user.is_authenticated %}
1919
<link rel="stylesheet" href="{% static 'core/css/login.css' %}" />
2020
{% endif %}
21-
{% if sentry_browser_loader_src and sentry_browser_config %}
21+
{% if sentry_browser_bundle_src and sentry_browser_config %}
2222
{{ sentry_browser_config|json_script:"sentry-browser-config" }}
23+
<script src="{{ sentry_browser_bundle_src }}"></script>
2324
<script>
24-
window.sentryOnLoad = function () {
25+
(function () {
2526
var configElement = document.getElementById("sentry-browser-config");
26-
if (!configElement) {
27+
if (!configElement || !(window.Sentry && window.Sentry.init)) {
2728
return;
2829
}
2930

3031
var config = JSON.parse(configElement.textContent);
31-
Sentry.init({
32+
window.Sentry.init({
3233
dsn: config.dsn,
3334
environment: config.environment,
3435
release: config.release || undefined,
35-
integrations: [Sentry.browserTracingIntegration()],
36+
tunnel: config.tunnel,
37+
integrations: [window.Sentry.browserTracingIntegration()],
3638
tracesSampleRate: config.tracesSampleRate
3739
});
38-
};
40+
})();
3941
</script>
40-
<script src="{{ sentry_browser_loader_src }}" crossorigin="anonymous" data-lazy="no"></script>
4142
{% endif %}
4243
{% block extra_head %}{% endblock %}
4344
</head>

astra_app/core/tests/test_sentry_browser_templates.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from types import SimpleNamespace
22
from unittest.mock import patch
33

4+
from django.contrib.staticfiles.storage import staticfiles_storage
45
from django.test import SimpleTestCase, TestCase, override_settings
56

67
from core.context_processors import build_info
@@ -14,12 +15,12 @@ class SentryBrowserContextTests(SimpleTestCase):
1415
SENTRY_RELEASE="build-123",
1516
SENTRY_TRACES_SAMPLE_RATE=0.25,
1617
)
17-
def test_build_info_exposes_sentry_browser_loader_and_config(self) -> None:
18+
def test_build_info_exposes_sentry_browser_bundle_and_config(self) -> None:
1819
context = build_info(SimpleNamespace())
1920

2021
self.assertEqual(
21-
context["sentry_browser_loader_src"],
22-
"https://js.sentry-cdn.com/public.min.js",
22+
context["sentry_browser_bundle_src"],
23+
staticfiles_storage.url("core/vendor/sentry/bundle.tracing.min.js"),
2324
)
2425
self.assertEqual(
2526
context["sentry_browser_config"],
@@ -28,14 +29,15 @@ def test_build_info_exposes_sentry_browser_loader_and_config(self) -> None:
2829
"environment": "staging",
2930
"release": "build-123",
3031
"tracesSampleRate": 0.25,
32+
"tunnel": "/_ci/envelope/",
3133
},
3234
)
3335

3436
@override_settings(SENTRY_DSN="")
3537
def test_build_info_omits_sentry_browser_config_without_dsn(self) -> None:
3638
context = build_info(SimpleNamespace())
3739

38-
self.assertEqual(context["sentry_browser_loader_src"], "")
40+
self.assertEqual(context["sentry_browser_bundle_src"], "")
3941
self.assertIsNone(context["sentry_browser_config"])
4042

4143

@@ -51,7 +53,7 @@ def _login_as_freeipa(self, username: str) -> None:
5153
SENTRY_RELEASE="build-123",
5254
SENTRY_TRACES_SAMPLE_RATE=0.25,
5355
)
54-
def test_profile_page_includes_sentry_browser_loader_and_config(self) -> None:
56+
def test_profile_page_includes_sentry_browser_bundle_and_tunnel_config(self) -> None:
5557
username = "admin"
5658
self._login_as_freeipa(username)
5759

@@ -71,10 +73,11 @@ def test_profile_page_includes_sentry_browser_loader_and_config(self) -> None:
7173
self.assertEqual(response.status_code, 200)
7274
self.assertContains(
7375
response,
74-
'src="https://js.sentry-cdn.com/public.min.js"',
76+
'src="/static/core/vendor/sentry/bundle.tracing.min.js"',
7577
)
7678
self.assertContains(response, 'id="sentry-browser-config"')
77-
self.assertContains(response, "window.sentryOnLoad")
79+
self.assertContains(response, "window.Sentry && window.Sentry.init")
7880
self.assertContains(response, '"environment": "staging"')
7981
self.assertContains(response, '"release": "build-123"')
80-
self.assertContains(response, '"tracesSampleRate": 0.25')
82+
self.assertContains(response, '"tracesSampleRate": 0.25')
83+
self.assertContains(response, '"tunnel": "/_ci/envelope/"')
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from unittest.mock import Mock, patch
2+
3+
import requests
4+
from django.test import SimpleTestCase, override_settings
5+
6+
7+
@override_settings(SENTRY_DSN="https://public@example.ingest.sentry.io/1")
8+
class SentryTunnelViewTests(SimpleTestCase):
9+
def test_tunnel_url_is_wired(self) -> None:
10+
response = self.client.get("/_ci/envelope/")
11+
12+
self.assertEqual(response.status_code, 405)
13+
14+
def test_tunnel_rejects_envelope_without_matching_dsn(self) -> None:
15+
response = self.client.post(
16+
"/_ci/envelope/",
17+
data=b'{"dsn":"https://public@other.ingest.sentry.io/2"}\n{}',
18+
content_type="application/x-sentry-envelope",
19+
)
20+
21+
self.assertEqual(response.status_code, 400)
22+
self.assertEqual(response.json(), {"ok": False, "error": "Invalid Sentry envelope DSN."})
23+
24+
def test_tunnel_forwards_matching_envelope(self) -> None:
25+
upstream_response = Mock()
26+
upstream_response.status_code = 200
27+
upstream_response.content = b""
28+
upstream_response.headers = {"Content-Type": "text/plain"}
29+
30+
envelope = b'{"dsn":"https://public@example.ingest.sentry.io/1"}\n{"type":"transaction"}\n{}'
31+
32+
with patch("core.views_sentry.requests.post", return_value=upstream_response) as post_mock:
33+
response = self.client.post(
34+
"/_ci/envelope/",
35+
data=envelope,
36+
content_type="application/x-sentry-envelope",
37+
)
38+
39+
self.assertEqual(response.status_code, 200)
40+
post_mock.assert_called_once_with(
41+
"https://example.ingest.sentry.io/api/1/envelope/",
42+
data=envelope,
43+
headers={"Content-Type": "application/x-sentry-envelope"},
44+
timeout=5,
45+
allow_redirects=False,
46+
)
47+
48+
def test_tunnel_returns_bad_gateway_when_upstream_fails(self) -> None:
49+
envelope = b'{"dsn":"https://public@example.ingest.sentry.io/1"}\n{"type":"transaction"}\n{}'
50+
51+
with patch(
52+
"core.views_sentry.requests.post",
53+
side_effect=requests.RequestException("downstream failed"),
54+
):
55+
response = self.client.post(
56+
"/_ci/envelope/",
57+
data=envelope,
58+
content_type="application/x-sentry-envelope",
59+
)
60+
61+
self.assertEqual(response.status_code, 502)
62+
self.assertEqual(response.json(), {"ok": False, "error": "Failed to forward Sentry envelope."})

astra_app/core/urls.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
views_organizations,
1313
views_search,
1414
views_send_mail,
15+
views_sentry,
1516
views_settings,
1617
views_static,
1718
views_templated_email,
@@ -21,6 +22,7 @@
2122
urlpatterns = [
2223
path("", views_users.home, name="home"),
2324
path("favicon.ico", RedirectView.as_view(url=staticfiles_storage.url("core/images/fav/favicon.ico"), permanent=True)),
25+
path("_ci/envelope/", views_sentry.sentry_browser_tunnel, name="sentry-browser-tunnel"),
2426
path("users/", views_users.users, name="users"),
2527
path("users/grid/", views_users.users_grid, name="users-grid"),
2628
path("user/<str:username>/", views_users.user_profile, name="user-profile"),

astra_app/core/views_sentry.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import json
2+
import logging
3+
from urllib.parse import urlsplit
4+
5+
import requests
6+
from django.conf import settings
7+
from django.http import HttpRequest, HttpResponse, JsonResponse
8+
from django.views.decorators.csrf import csrf_exempt
9+
from django.views.decorators.http import require_POST
10+
11+
logger = logging.getLogger(__name__)
12+
13+
_SENTRY_TUNNEL_TIMEOUT_SECONDS = 5
14+
15+
16+
def _parse_sentry_dsn(dsn: str) -> tuple[str, str, int | None, str, str] | None:
17+
parsed = urlsplit(dsn)
18+
project_id = parsed.path.strip("/")
19+
if not parsed.scheme or not parsed.hostname or not parsed.username or not project_id:
20+
return None
21+
22+
return (parsed.scheme, parsed.hostname, parsed.port, parsed.username, project_id)
23+
24+
25+
def _configured_sentry_envelope_url() -> str | None:
26+
parsed_dsn = _parse_sentry_dsn(settings.SENTRY_DSN)
27+
if parsed_dsn is None:
28+
return None
29+
30+
scheme, hostname, port, _public_key, project_id = parsed_dsn
31+
netloc = f"{hostname}:{port}" if port else hostname
32+
return f"{scheme}://{netloc}/api/{project_id}/envelope/"
33+
34+
35+
def _read_envelope_dsn(request: HttpRequest) -> str | None:
36+
if not request.body:
37+
return None
38+
39+
header_line = request.body.split(b"\n", maxsplit=1)[0]
40+
if not header_line:
41+
return None
42+
43+
try:
44+
header_payload = json.loads(header_line.decode("utf-8"))
45+
except (UnicodeDecodeError, json.JSONDecodeError):
46+
return None
47+
48+
dsn = header_payload.get("dsn")
49+
return str(dsn).strip() if dsn else None
50+
51+
52+
@csrf_exempt
53+
@require_POST
54+
def sentry_browser_tunnel(request: HttpRequest) -> HttpResponse:
55+
upstream_url = _configured_sentry_envelope_url()
56+
expected_dsn = _parse_sentry_dsn(settings.SENTRY_DSN)
57+
request_dsn = _parse_sentry_dsn(_read_envelope_dsn(request) or "")
58+
59+
if upstream_url is None or expected_dsn is None or request_dsn != expected_dsn:
60+
return JsonResponse({"ok": False, "error": "Invalid Sentry envelope DSN."}, status=400)
61+
62+
try:
63+
upstream_response = requests.post(
64+
upstream_url,
65+
data=request.body,
66+
headers={"Content-Type": request.content_type or "application/x-sentry-envelope"},
67+
timeout=_SENTRY_TUNNEL_TIMEOUT_SECONDS,
68+
allow_redirects=False,
69+
)
70+
except requests.RequestException:
71+
logger.warning("Failed to forward Sentry envelope", exc_info=True)
72+
return JsonResponse({"ok": False, "error": "Failed to forward Sentry envelope."}, status=502)
73+
74+
response = HttpResponse(
75+
content=upstream_response.content,
76+
status=upstream_response.status_code,
77+
content_type=upstream_response.headers.get("Content-Type", "text/plain"),
78+
)
79+
return response

0 commit comments

Comments
 (0)