Browse Source

feat(loader): Add project dsn settings for dynamic sdk loader (#44496)

ref https://github.com/getsentry/sentry/issues/44225

Building on the work from
https://github.com/getsentry/sentry/pull/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.
Abhijeet Prasad 2 years ago
parent
commit
4c46196fd7

+ 16 - 4
src/sentry/api/endpoints/project_key_details.py

@@ -56,10 +56,22 @@ class ProjectKeyDetailsEndpoint(ProjectEndpoint):
             if result.get("name"):
                 key.label = result["name"]
 
-            if not result.get("browserSdkVersion"):
-                key.data = {"browserSdkVersion": default_version}
-            else:
-                key.data = {"browserSdkVersion": result["browserSdkVersion"]}
+            if not key.data:
+                key.data = {}
+
+            key.data["browserSdkVersion"] = (
+                default_version
+                if not result.get("browserSdkVersion")
+                else result["browserSdkVersion"]
+            )
+
+            result_dynamic_sdk_options = result.get("dynamicSdkLoaderOptions")
+
+            if result_dynamic_sdk_options:
+                if key.data.get("dynamicSdkLoaderOptions"):
+                    key.data["dynamicSdkLoaderOptions"].update(result_dynamic_sdk_options)
+                else:
+                    key.data["dynamicSdkLoaderOptions"] = result_dynamic_sdk_options
 
             if result.get("isActive") is True:
                 key.status = ProjectKeyStatus.ACTIVE

+ 8 - 0
src/sentry/api/serializers/models/project_key.py

@@ -3,6 +3,7 @@ from sentry.loader.browsersdkversion import (
     get_browser_sdk_version_choices,
     get_selected_browser_sdk_version,
 )
+from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption, get_dynamic_sdk_loader_option
 from sentry.models import ProjectKey
 
 
@@ -35,5 +36,12 @@ class ProjectKeySerializer(Serializer):
             "browserSdkVersion": get_selected_browser_sdk_version(obj),
             "browserSdk": {"choices": get_browser_sdk_version_choices()},
             "dateCreated": obj.date_added,
+            "dynamicSdkLoaderOptions": {
+                "hasReplay": get_dynamic_sdk_loader_option(obj, DynamicSdkLoaderOption.HAS_REPLAY),
+                "hasPerformance": get_dynamic_sdk_loader_option(
+                    obj, DynamicSdkLoaderOption.HAS_PERFORMANCE
+                ),
+                "hasDebug": get_dynamic_sdk_loader_option(obj, DynamicSdkLoaderOption.HAS_DEBUG),
+            },
         }
         return d

+ 21 - 0
src/sentry/api/serializers/rest_framework/project_key.py

@@ -2,6 +2,7 @@ from rest_framework import serializers
 
 from sentry.api.fields.empty_integer import EmptyIntegerField
 from sentry.loader.browsersdkversion import get_browser_sdk_version_choices
+from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption
 
 
 class RateLimitSerializer(serializers.Serializer):
@@ -9,6 +10,23 @@ class RateLimitSerializer(serializers.Serializer):
     window = EmptyIntegerField(min_value=0, max_value=60 * 60 * 24, required=False, allow_null=True)
 
 
+class DynamicSdkLoaderOptionSerializer(serializers.Serializer):
+    hasReplay = serializers.BooleanField(required=False)
+    hasPerformance = serializers.BooleanField(required=False)
+    hasDebug = serializers.BooleanField(required=False)
+
+    def to_internal_value(self, data):
+        # Drop any fields that are not specified as a `DynamicSdkLoaderOption`.
+        allowed = {option.value for option in DynamicSdkLoaderOption}
+        existing = set(data)
+
+        new_data = {}
+        for field_name in existing.intersection(allowed):
+            new_data[field_name] = data[field_name]
+
+        return super().to_internal_value(new_data)
+
+
 class ProjectKeySerializer(serializers.Serializer):
     name = serializers.CharField(max_length=64, required=False, allow_blank=True, allow_null=True)
     public = serializers.RegexField(r"^[a-f0-9]{32}$", required=False, allow_null=True)
@@ -18,3 +36,6 @@ class ProjectKeySerializer(serializers.Serializer):
     browserSdkVersion = serializers.ChoiceField(
         choices=get_browser_sdk_version_choices(), required=False
     )
+    dynamicSdkLoaderOptions = DynamicSdkLoaderOptionSerializer(
+        required=False, allow_null=True, partial=True
+    )

+ 12 - 0
src/sentry/loader/dynamic_sdk_options.py

@@ -0,0 +1,12 @@
+from enum import Enum
+
+
+class DynamicSdkLoaderOption(str, Enum):
+    HAS_REPLAY = "hasReplay"
+    HAS_PERFORMANCE = "hasPerformance"
+    HAS_DEBUG = "hasDebug"
+
+
+def get_dynamic_sdk_loader_option(project_key, option: DynamicSdkLoaderOption, default=False):
+    dynamic_sdk_loader_options = project_key.data.get("dynamicSdkLoaderOptions", {})
+    return dynamic_sdk_loader_options.get(option.value, default)

+ 88 - 1
src/sentry/web/frontend/js_sdk_dynamic_loader.py

@@ -1,7 +1,28 @@
+from typing import Optional, Tuple, TypedDict
+
+from django.conf import settings
 from rest_framework.request import Request
 from rest_framework.response import Response
 
+from sentry.loader.browsersdkversion import get_browser_sdk_version
+from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption, get_dynamic_sdk_loader_option
+from sentry.models import Project, ProjectKey
 from sentry.web.frontend.base import BaseView
+from sentry.web.helpers import render_to_response
+
+CACHE_CONTROL = (
+    "public, max-age=3600, s-maxage=60, stale-while-revalidate=315360000, stale-if-error=315360000"
+)
+
+
+class SdkConfig(TypedDict):
+    dsn: str
+
+
+class LoaderContext(TypedDict):
+    config: SdkConfig
+    jsSdkUrl: Optional[str]
+    publicKey: Optional[str]
 
 
 class JavaScriptSdkDynamicLoader(BaseView):
@@ -13,6 +34,72 @@ class JavaScriptSdkDynamicLoader(BaseView):
     def determine_active_organization(self, request: Request, organization_slug=None) -> None:
         pass
 
+    def _get_context(self, key: ProjectKey) -> Tuple[LoaderContext, Optional[str], Optional[str]]:
+        """Sets context information needed to render the loader"""
+        if not key:
+            return ({}, None, None)
+
+        sdk_version = get_browser_sdk_version(key)
+
+        bundle_kind_modifier = self._get_bundle_kind_modifier(key)
+
+        sdk_url = ""
+        try:
+            sdk_url = settings.JS_SDK_LOADER_DEFAULT_SDK_URL % (sdk_version, bundle_kind_modifier)
+        except TypeError:
+            sdk_url = ""  # It fails if it cannot inject the version in the string
+
+        return (
+            {
+                "config": {
+                    "dsn": key.dsn_public,
+                    "jsSdkUrl": sdk_url,
+                    "publicKey": key.public_key,
+                }
+            },
+            sdk_version,
+            sdk_url,
+        )
+
+    def _get_bundle_kind_modifier(self, key: ProjectKey) -> str:
+        """Returns a string that is used to modify the bundle name"""
+        bundle_kind_modifier = ""
+
+        if get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_PERFORMANCE):
+            bundle_kind_modifier += ".tracing"
+
+        if get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_REPLAY):
+            bundle_kind_modifier += ".replay"
+
+        # TODO(abhi): Right now this loader only supports returning es6 JS bundles.
+        # We may want to re-evaluate this.
+        # if es5
+        # bundle_kind_modifier += ".es5"
+
+        if get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_DEBUG):
+            bundle_kind_modifier += ".debug"
+
+        return bundle_kind_modifier
+
     def get(self, request: Request, public_key: str, minified: str) -> Response:
         """Returns a JS file that dynamically loads the SDK based on project settings"""
-        return super().get(request)
+        key = None
+        try:
+            key = ProjectKey.objects.get_from_cache(public_key=public_key)
+        except ProjectKey.DoesNotExist:
+            pass
+        else:
+            key.project = Project.objects.get_from_cache(id=key.project_id)
+
+        # TODO(abhi): Return more than no-op template
+        tmpl = "sentry/js-sdk-loader-noop.js.tmpl"
+
+        context, sdk_version, sdk_url = self._get_context(key)
+
+        response = render_to_response(tmpl, context, content_type="text/javascript")
+
+        response["Access-Control-Allow-Origin"] = "*"
+        response["Cross-Origin-Resource-Policy"] = "cross-origin"
+        response["Cache-Control"] = CACHE_CONTROL
+
+        return response

+ 146 - 0
tests/sentry/api/endpoints/test_project_key_details.py

@@ -1,5 +1,6 @@
 from django.urls import reverse
 
+from sentry.loader.browsersdkversion import get_default_sdk_version_for_project
 from sentry.models import ProjectKey, ProjectKeyStatus
 from sentry.testutils import APITestCase
 from sentry.testutils.silo import region_silo_test
@@ -116,6 +117,151 @@ class UpdateProjectKeyTest(APITestCase):
         assert key.label == "hello world"
         assert key.status == ProjectKeyStatus.INACTIVE
 
+    def test_default_browser_sdk_version(self):
+        project = self.create_project()
+        key = ProjectKey.objects.get_or_create(project=project)[0]
+        self.login_as(user=self.user)
+        url = reverse(
+            "sentry-api-0-project-key-details",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+                "key_id": key.public_key,
+            },
+        )
+        response = self.client.put(url, {})
+        assert response.status_code == 200
+        key = ProjectKey.objects.get(id=key.id)
+        assert key.data["browserSdkVersion"] == get_default_sdk_version_for_project(project)
+
+    def test_set_browser_sdk_version(self):
+        project = self.create_project()
+        key = ProjectKey.objects.get_or_create(project=project)[0]
+        self.login_as(user=self.user)
+        url = reverse(
+            "sentry-api-0-project-key-details",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+                "key_id": key.public_key,
+            },
+        )
+        response = self.client.put(url, {"browserSdkVersion": "5.x"})
+        assert response.status_code == 200
+        key = ProjectKey.objects.get(id=key.id)
+        assert key.data["browserSdkVersion"] == "5.x"
+
+    def test_empty_dynamic_sdk_loader_options(self):
+        project = self.create_project()
+        key = ProjectKey.objects.get_or_create(project=project)[0]
+        self.login_as(user=self.user)
+        url = reverse(
+            "sentry-api-0-project-key-details",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+                "key_id": key.public_key,
+            },
+        )
+        response = self.client.put(url, {})
+        assert response.status_code == 200
+        key = ProjectKey.objects.get(id=key.id)
+        assert "dynamicSdkLoaderOptions" not in key.data
+
+    def test_dynamic_sdk_loader_options(self):
+        project = self.create_project()
+        key = ProjectKey.objects.get_or_create(project=project)[0]
+        self.login_as(user=self.user)
+        url = reverse(
+            "sentry-api-0-project-key-details",
+            kwargs={
+                "organization_slug": project.organization.slug,
+                "project_slug": project.slug,
+                "key_id": key.public_key,
+            },
+        )
+        response = self.client.put(
+            url,
+            {"dynamicSdkLoaderOptions": {}},
+        )
+        assert response.status_code == 200
+        key = ProjectKey.objects.get(id=key.id)
+        assert key.data.get("dynamicSdkLoaderOptions") is None
+
+        response = self.client.put(
+            url,
+            {
+                "dynamicSdkLoaderOptions": {
+                    "hasReplay": True,
+                }
+            },
+        )
+        assert response.status_code == 200
+        key = ProjectKey.objects.get(id=key.id)
+        assert key.data.get("dynamicSdkLoaderOptions") == {
+            "hasReplay": True,
+        }
+
+        response = self.client.put(
+            url,
+            {
+                "dynamicSdkLoaderOptions": {
+                    "hasReplay": False,
+                    "hasPerformance": True,
+                }
+            },
+        )
+        assert response.status_code == 200
+        key = ProjectKey.objects.get(id=key.id)
+        assert key.data.get("dynamicSdkLoaderOptions") == {
+            "hasReplay": False,
+            "hasPerformance": True,
+        }
+
+        response = self.client.put(
+            url,
+            {"dynamicSdkLoaderOptions": {"hasDebug": True, "invalid-key": "blah"}},
+        )
+        assert response.status_code == 200
+        key = ProjectKey.objects.get(id=key.id)
+        assert key.data.get("dynamicSdkLoaderOptions") == {
+            "hasReplay": False,
+            "hasPerformance": True,
+            "hasDebug": True,
+        }
+
+        response = self.client.put(
+            url,
+            {
+                "dynamicSdkLoaderOptions": {
+                    "hasReplay": "invalid",
+                }
+            },
+        )
+        assert response.status_code == 400
+        key = ProjectKey.objects.get(id=key.id)
+        assert key.data.get("dynamicSdkLoaderOptions") == {
+            "hasReplay": False,
+            "hasPerformance": True,
+            "hasDebug": True,
+        }
+
+        response = self.client.put(
+            url,
+            {
+                "dynamicSdkLoaderOptions": {
+                    "invalid-key": "blah",
+                }
+            },
+        )
+        assert response.status_code == 200
+        key = ProjectKey.objects.get(id=key.id)
+        assert key.data.get("dynamicSdkLoaderOptions") == {
+            "hasReplay": False,
+            "hasPerformance": True,
+            "hasDebug": True,
+        }
+
 
 @region_silo_test
 class DeleteProjectKeyTest(APITestCase):

+ 9 - 0
tests/sentry/api/serializers/rest_framework/test_project_key.py

@@ -0,0 +1,9 @@
+from sentry.api.serializers.rest_framework import DynamicSdkLoaderOptionSerializer
+from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption
+from sentry.testutils import TestCase
+
+
+class ProjectKeySerializerTest(TestCase):
+    def test_dynamic_sdk_serializer_attrs(self):
+        s = DynamicSdkLoaderOptionSerializer()
+        assert set(s.fields.keys()) == {option.value for option in DynamicSdkLoaderOption}

+ 28 - 0
tests/sentry/loader/test_dynamic_sdk_options.py

@@ -0,0 +1,28 @@
+from sentry.loader.dynamic_sdk_options import DynamicSdkLoaderOption, get_dynamic_sdk_loader_option
+from sentry.models import ProjectKey
+from sentry.testutils import TestCase
+
+
+class DynamicSdkOptions(TestCase):
+    def test_default_get_dynamic_sdk_loader_option(self):
+        key = ProjectKey(project_id=1, public_key="public", secret_key="secret")
+        assert not get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_REPLAY)
+        assert not get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_PERFORMANCE)
+        assert not get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_DEBUG)
+
+    def test_get_dynamic_sdk_loader_option(self):
+        dynamic_sdk_loader_options = {}
+        dynamic_sdk_loader_options[DynamicSdkLoaderOption.HAS_REPLAY.value] = True
+        dynamic_sdk_loader_options[DynamicSdkLoaderOption.HAS_PERFORMANCE.value] = True
+        dynamic_sdk_loader_options[DynamicSdkLoaderOption.HAS_DEBUG.value] = True
+
+        key = ProjectKey(
+            project_id=1,
+            public_key="public",
+            secret_key="secret",
+            data={"dynamicSdkLoaderOptions": dynamic_sdk_loader_options},
+        )
+
+        assert get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_REPLAY)
+        assert get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_PERFORMANCE)
+        assert get_dynamic_sdk_loader_option(key, DynamicSdkLoaderOption.HAS_DEBUG)

+ 25 - 0
tests/sentry/web/frontend/test_js_sdk_dynamic_loader.py

@@ -1,3 +1,6 @@
+from functools import cached_property
+
+import pytest
 from django.conf import settings
 from django.urls import reverse
 
@@ -5,6 +8,28 @@ from sentry.testutils import TestCase
 
 
 class JavaScriptSdkLoaderTest(TestCase):
+    @pytest.fixture(autouse=True)
+    def set_settings(self):
+        settings.JS_SDK_LOADER_SDK_VERSION = "0.5.2"
+        settings.JS_SDK_LOADER_DEFAULT_SDK_URL = "https://browser.sentry-cdn.com/%s/bundle%s.min.js"
+
+    @cached_property
+    def path(self):
+        return reverse("sentry-js-sdk-dynamic-loader", args=[self.projectkey.public_key])
+
+    def test_noop_no_pub_key(self):
+        resp = self.client.get(reverse("sentry-js-sdk-dynamic-loader", args=["abc"]))
+        assert resp.status_code == 200
+        self.assertTemplateUsed(resp, "sentry/js-sdk-loader-noop.js.tmpl")
+
+    def test_noop(self):
+        settings.JS_SDK_LOADER_DEFAULT_SDK_URL = ""
+        resp = self.client.get(
+            reverse("sentry-js-sdk-dynamic-loader", args=[self.projectkey.public_key])
+        )
+        assert resp.status_code == 200
+        self.assertTemplateUsed(resp, "sentry/js-sdk-loader-noop.js.tmpl")
+
     def test_absolute_url(self):
         assert (
             reverse("sentry-js-sdk-dynamic-loader", args=[self.projectkey.public_key, ".min"])