Skip to content

Commit ef238fc

Browse files
AbhiPrasadwmak
authored andcommitted
feat(loader): Add project dsn settings for dynamic sdk loader (#44496)
ref #44225 Building on the work from #44346, this PR adds `dynamicSdkLoaderOptions`, a dictionary of options for the new dynamic SDK loader. The `dynamicSdkLoaderOptions` live on the data `JSONField` on the `ProjectKey` model, as the there is a dynamic loader unique to each DSN. `dynamicSdkLoaderOptions` is also a dictionary, for ease of use, with 3 keys: 1. `hasReplay`: If the loader should include the replay sdk in the bundle 2. `hasPerformance`: If the loader should include the tracing sdk in the bundle 3. `hasDebug`: If the loader should load the debug bundle In the future we could migrate this onto the model directly (as a `BitField` or something), but for now for iteration speed and fluid schema, adding it as a JSON is good enough. To validate we are using the correct fields, `dynamicSdkLoaderOptions` is validated in the `ProjectKeySerializer` via a custom serializer. These new options are used by the `_get_bundle_kind_modifier` method in the `JavaScriptSdkDynamicLoader` view, but for now are not used since the templates are not added (we render a no-op template instead). In the next PR we will add templates for the loader view, alongside tests to validate this all together.
1 parent 79e6868 commit ef238fc

File tree

9 files changed

+353
-5
lines changed

9 files changed

+353
-5
lines changed

src/sentry/api/endpoints/project_key_details.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,22 @@ def put(self, request: Request, project, key_id) -> Response:
5656
if result.get("name"):
5757
key.label = result["name"]
5858

59-
if not result.get("browserSdkVersion"):
60-
key.data = {"browserSdkVersion": default_version}
61-
else:
62-
key.data = {"browserSdkVersion": result["browserSdkVersion"]}
59+
if not key.data:
60+
key.data = {}
61+
62+
key.data["browserSdkVersion"] = (
63+
default_version
64+
if not result.get("browserSdkVersion")
65+
else result["browserSdkVersion"]
66+
)
67+
68+
result_dynamic_sdk_options = result.get("dynamicSdkLoaderOptions")
69+
70+
if result_dynamic_sdk_options:
71+
if key.data.get("dynamicSdkLoaderOptions"):
72+
key.data["dynamicSdkLoaderOptions"].update(result_dynamic_sdk_options)
73+
else:
74+
key.data["dynamicSdkLoaderOptions"] = result_dynamic_sdk_options
6375

6476
if result.get("isActive") is True:
6577
key.status = ProjectKeyStatus.ACTIVE

src/sentry/api/serializers/models/project_key.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
get_browser_sdk_version_choices,
44
get_selected_browser_sdk_version,
55
)
6+
from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption, get_dynamic_sdk_loader_option
67
from sentry.models import ProjectKey
78

89

@@ -35,5 +36,12 @@ def serialize(self, obj, attrs, user):
3536
"browserSdkVersion": get_selected_browser_sdk_version(obj),
3637
"browserSdk": {"choices": get_browser_sdk_version_choices()},
3738
"dateCreated": obj.date_added,
39+
"dynamicSdkLoaderOptions": {
40+
"hasReplay": get_dynamic_sdk_loader_option(obj, DynamicSdkLoaderOption.HAS_REPLAY),
41+
"hasPerformance": get_dynamic_sdk_loader_option(
42+
obj, DynamicSdkLoaderOption.HAS_PERFORMANCE
43+
),
44+
"hasDebug": get_dynamic_sdk_loader_option(obj, DynamicSdkLoaderOption.HAS_DEBUG),
45+
},
3846
}
3947
return d

src/sentry/api/serializers/rest_framework/project_key.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,31 @@
22

33
from sentry.api.fields.empty_integer import EmptyIntegerField
44
from sentry.loader.browsersdkversion import get_browser_sdk_version_choices
5+
from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption
56

67

78
class RateLimitSerializer(serializers.Serializer):
89
count = EmptyIntegerField(min_value=0, required=False, allow_null=True)
910
window = EmptyIntegerField(min_value=0, max_value=60 * 60 * 24, required=False, allow_null=True)
1011

1112

13+
class DynamicSdkLoaderOptionSerializer(serializers.Serializer):
14+
hasReplay = serializers.BooleanField(required=False)
15+
hasPerformance = serializers.BooleanField(required=False)
16+
hasDebug = serializers.BooleanField(required=False)
17+
18+
def to_internal_value(self, data):
19+
# Drop any fields that are not specified as a `DynamicSdkLoaderOption`.
20+
allowed = {option.value for option in DynamicSdkLoaderOption}
21+
existing = set(data)
22+
23+
new_data = {}
24+
for field_name in existing.intersection(allowed):
25+
new_data[field_name] = data[field_name]
26+
27+
return super().to_internal_value(new_data)
28+
29+
1230
class ProjectKeySerializer(serializers.Serializer):
1331
name = serializers.CharField(max_length=64, required=False, allow_blank=True, allow_null=True)
1432
public = serializers.RegexField(r"^[a-f0-9]{32}$", required=False, allow_null=True)
@@ -18,3 +36,6 @@ class ProjectKeySerializer(serializers.Serializer):
1836
browserSdkVersion = serializers.ChoiceField(
1937
choices=get_browser_sdk_version_choices(), required=False
2038
)
39+
dynamicSdkLoaderOptions = DynamicSdkLoaderOptionSerializer(
40+
required=False, allow_null=True, partial=True
41+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from enum import Enum
2+
3+
4+
class DynamicSdkLoaderOption(str, Enum):
5+
HAS_REPLAY = "hasReplay"
6+
HAS_PERFORMANCE = "hasPerformance"
7+
HAS_DEBUG = "hasDebug"
8+
9+
10+
def get_dynamic_sdk_loader_option(project_key, option: DynamicSdkLoaderOption, default=False):
11+
dynamic_sdk_loader_options = project_key.data.get("dynamicSdkLoaderOptions", {})
12+
return dynamic_sdk_loader_options.get(option.value, default)

src/sentry/web/frontend/js_sdk_dynamic_loader.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,28 @@
1+
from typing import Optional, Tuple, TypedDict
2+
3+
from django.conf import settings
14
from rest_framework.request import Request
25
from rest_framework.response import Response
36

7+
from sentry.loader.browsersdkversion import get_browser_sdk_version
8+
from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption, get_dynamic_sdk_loader_option
9+
from sentry.models import Project, ProjectKey
410
from sentry.web.frontend.base import BaseView
11+
from sentry.web.helpers import render_to_response
12+
13+
CACHE_CONTROL = (
14+
"public, max-age=3600, s-maxage=60, stale-while-revalidate=315360000, stale-if-error=315360000"
15+
)
16+
17+
18+
class SdkConfig(TypedDict):
19+
dsn: str
20+
21+
22+
class LoaderContext(TypedDict):
23+
config: SdkConfig
24+
jsSdkUrl: Optional[str]
25+
publicKey: Optional[str]
526

627

728
class JavaScriptSdkDynamicLoader(BaseView):
@@ -13,6 +34,72 @@ class JavaScriptSdkDynamicLoader(BaseView):
1334
def determine_active_organization(self, request: Request, organization_slug=None) -> None:
1435
pass
1536

37+
def _get_context(self, key: ProjectKey) -> Tuple[LoaderContext, Optional[str], Optional[str]]:
38+
"""Sets context information needed to render the loader"""
39+
if not key:
40+
return ({}, None, None)
41+
42+
sdk_version = get_browser_sdk_version(key)
43+
44+
bundle_kind_modifier = self._get_bundle_kind_modifier(key)
45+
46+
sdk_url = ""
47+
try:
48+
sdk_url = settings.JS_SDK_LOADER_DEFAULT_SDK_URL % (sdk_version, bundle_kind_modifier)
49+
except TypeError:
50+
sdk_url = "" # It fails if it cannot inject the version in the string
51+
52+
return (
53+
{
54+
"config": {
55+
"dsn": key.dsn_public,
56+
"jsSdkUrl": sdk_url,
57+
"publicKey": key.public_key,
58+
}
59+
},
60+
sdk_version,
61+
sdk_url,
62+
)
63+
64+
def _get_bundle_kind_modifier(self, key: ProjectKey) -> str:
65+
"""Returns a string that is used to modify the bundle name"""
66+
bundle_kind_modifier = ""
67+
68+
if get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_PERFORMANCE):
69+
bundle_kind_modifier += ".tracing"
70+
71+
if get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_REPLAY):
72+
bundle_kind_modifier += ".replay"
73+
74+
# TODO(abhi): Right now this loader only supports returning es6 JS bundles.
75+
# We may want to re-evaluate this.
76+
# if es5
77+
# bundle_kind_modifier += ".es5"
78+
79+
if get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_DEBUG):
80+
bundle_kind_modifier += ".debug"
81+
82+
return bundle_kind_modifier
83+
1684
def get(self, request: Request, public_key: str, minified: str) -> Response:
1785
"""Returns a JS file that dynamically loads the SDK based on project settings"""
18-
return super().get(request)
86+
key = None
87+
try:
88+
key = ProjectKey.objects.get_from_cache(public_key=public_key)
89+
except ProjectKey.DoesNotExist:
90+
pass
91+
else:
92+
key.project = Project.objects.get_from_cache(id=key.project_id)
93+
94+
# TODO(abhi): Return more than no-op template
95+
tmpl = "sentry/js-sdk-loader-noop.js.tmpl"
96+
97+
context, sdk_version, sdk_url = self._get_context(key)
98+
99+
response = render_to_response(tmpl, context, content_type="text/javascript")
100+
101+
response["Access-Control-Allow-Origin"] = "*"
102+
response["Cross-Origin-Resource-Policy"] = "cross-origin"
103+
response["Cache-Control"] = CACHE_CONTROL
104+
105+
return response

tests/sentry/api/endpoints/test_project_key_details.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.urls import reverse
22

3+
from sentry.loader.browsersdkversion import get_default_sdk_version_for_project
34
from sentry.models import ProjectKey, ProjectKeyStatus
45
from sentry.testutils import APITestCase
56
from sentry.testutils.silo import region_silo_test
@@ -116,6 +117,151 @@ def test_deactivate(self):
116117
assert key.label == "hello world"
117118
assert key.status == ProjectKeyStatus.INACTIVE
118119

120+
def test_default_browser_sdk_version(self):
121+
project = self.create_project()
122+
key = ProjectKey.objects.get_or_create(project=project)[0]
123+
self.login_as(user=self.user)
124+
url = reverse(
125+
"sentry-api-0-project-key-details",
126+
kwargs={
127+
"organization_slug": project.organization.slug,
128+
"project_slug": project.slug,
129+
"key_id": key.public_key,
130+
},
131+
)
132+
response = self.client.put(url, {})
133+
assert response.status_code == 200
134+
key = ProjectKey.objects.get(id=key.id)
135+
assert key.data["browserSdkVersion"] == get_default_sdk_version_for_project(project)
136+
137+
def test_set_browser_sdk_version(self):
138+
project = self.create_project()
139+
key = ProjectKey.objects.get_or_create(project=project)[0]
140+
self.login_as(user=self.user)
141+
url = reverse(
142+
"sentry-api-0-project-key-details",
143+
kwargs={
144+
"organization_slug": project.organization.slug,
145+
"project_slug": project.slug,
146+
"key_id": key.public_key,
147+
},
148+
)
149+
response = self.client.put(url, {"browserSdkVersion": "5.x"})
150+
assert response.status_code == 200
151+
key = ProjectKey.objects.get(id=key.id)
152+
assert key.data["browserSdkVersion"] == "5.x"
153+
154+
def test_empty_dynamic_sdk_loader_options(self):
155+
project = self.create_project()
156+
key = ProjectKey.objects.get_or_create(project=project)[0]
157+
self.login_as(user=self.user)
158+
url = reverse(
159+
"sentry-api-0-project-key-details",
160+
kwargs={
161+
"organization_slug": project.organization.slug,
162+
"project_slug": project.slug,
163+
"key_id": key.public_key,
164+
},
165+
)
166+
response = self.client.put(url, {})
167+
assert response.status_code == 200
168+
key = ProjectKey.objects.get(id=key.id)
169+
assert "dynamicSdkLoaderOptions" not in key.data
170+
171+
def test_dynamic_sdk_loader_options(self):
172+
project = self.create_project()
173+
key = ProjectKey.objects.get_or_create(project=project)[0]
174+
self.login_as(user=self.user)
175+
url = reverse(
176+
"sentry-api-0-project-key-details",
177+
kwargs={
178+
"organization_slug": project.organization.slug,
179+
"project_slug": project.slug,
180+
"key_id": key.public_key,
181+
},
182+
)
183+
response = self.client.put(
184+
url,
185+
{"dynamicSdkLoaderOptions": {}},
186+
)
187+
assert response.status_code == 200
188+
key = ProjectKey.objects.get(id=key.id)
189+
assert key.data.get("dynamicSdkLoaderOptions") is None
190+
191+
response = self.client.put(
192+
url,
193+
{
194+
"dynamicSdkLoaderOptions": {
195+
"hasReplay": True,
196+
}
197+
},
198+
)
199+
assert response.status_code == 200
200+
key = ProjectKey.objects.get(id=key.id)
201+
assert key.data.get("dynamicSdkLoaderOptions") == {
202+
"hasReplay": True,
203+
}
204+
205+
response = self.client.put(
206+
url,
207+
{
208+
"dynamicSdkLoaderOptions": {
209+
"hasReplay": False,
210+
"hasPerformance": True,
211+
}
212+
},
213+
)
214+
assert response.status_code == 200
215+
key = ProjectKey.objects.get(id=key.id)
216+
assert key.data.get("dynamicSdkLoaderOptions") == {
217+
"hasReplay": False,
218+
"hasPerformance": True,
219+
}
220+
221+
response = self.client.put(
222+
url,
223+
{"dynamicSdkLoaderOptions": {"hasDebug": True, "invalid-key": "blah"}},
224+
)
225+
assert response.status_code == 200
226+
key = ProjectKey.objects.get(id=key.id)
227+
assert key.data.get("dynamicSdkLoaderOptions") == {
228+
"hasReplay": False,
229+
"hasPerformance": True,
230+
"hasDebug": True,
231+
}
232+
233+
response = self.client.put(
234+
url,
235+
{
236+
"dynamicSdkLoaderOptions": {
237+
"hasReplay": "invalid",
238+
}
239+
},
240+
)
241+
assert response.status_code == 400
242+
key = ProjectKey.objects.get(id=key.id)
243+
assert key.data.get("dynamicSdkLoaderOptions") == {
244+
"hasReplay": False,
245+
"hasPerformance": True,
246+
"hasDebug": True,
247+
}
248+
249+
response = self.client.put(
250+
url,
251+
{
252+
"dynamicSdkLoaderOptions": {
253+
"invalid-key": "blah",
254+
}
255+
},
256+
)
257+
assert response.status_code == 200
258+
key = ProjectKey.objects.get(id=key.id)
259+
assert key.data.get("dynamicSdkLoaderOptions") == {
260+
"hasReplay": False,
261+
"hasPerformance": True,
262+
"hasDebug": True,
263+
}
264+
119265

120266
@region_silo_test
121267
class DeleteProjectKeyTest(APITestCase):
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from sentry.api.serializers.rest_framework import DynamicSdkLoaderOptionSerializer
2+
from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption
3+
from sentry.testutils import TestCase
4+
5+
6+
class ProjectKeySerializerTest(TestCase):
7+
def test_dynamic_sdk_serializer_attrs(self):
8+
s = DynamicSdkLoaderOptionSerializer()
9+
assert set(s.fields.keys()) == {option.value for option in DynamicSdkLoaderOption}

0 commit comments

Comments
 (0)