Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: sourcemaps #25300

Merged
merged 16 commits into from
Oct 2, 2024
Merged
8 changes: 8 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,10 @@ class ApiRequest {
return this.errorTrackingGroup(fingerprint).addPathComponent('merge')
}

public errorTrackingUploadSourceMaps(): ApiRequest {
return this.errorTracking().addPathComponent('upload_source_maps')
}

// # Warehouse
public dataWarehouseTables(teamId?: TeamType['id']): ApiRequest {
return this.projectsDetail(teamId).addPathComponent('warehouse_tables')
Expand Down Expand Up @@ -1775,6 +1779,10 @@ const api = {
.errorTrackingMerge(primaryFingerprint)
.create({ data: { merging_fingerprints: mergingFingerprints } })
},

async uploadSourceMaps(data: FormData): Promise<{ content: string }> {
return await new ApiRequest().errorTrackingUploadSourceMaps().create({ data })
},
},

recordings: {
Expand Down
76 changes: 75 additions & 1 deletion frontend/src/scenes/error-tracking/ErrorTrackingScene.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { TZLabel } from '@posthog/apps-common'
import { LemonButton, LemonCheckbox, LemonDivider, LemonSegmentedButton } from '@posthog/lemon-ui'
import { IconGear } from '@posthog/icons'
import {
LemonButton,
LemonCheckbox,
LemonDivider,
LemonFileInput,
LemonModal,
LemonSegmentedButton,
} from '@posthog/lemon-ui'
import clsx from 'clsx'
import { BindLogic, useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
import { FeedbackNotice } from 'lib/components/FeedbackNotice'
import { PageHeader } from 'lib/components/PageHeader'
import { IconUploadFile } from 'lib/lemon-ui/icons'
import { LemonField } from 'lib/lemon-ui/LemonField'
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
import { SceneExport } from 'scenes/sceneTypes'
import { urls } from 'scenes/urls'
Expand Down Expand Up @@ -51,6 +63,8 @@ export function ErrorTrackingScene(): JSX.Element {

return (
<BindLogic logic={errorTrackingDataNodeLogic} props={{ query, key: insightVizDataNodeKey(insightProps) }}>
<Header />
<ConfigurationModal />
<FeedbackNotice text="Error tracking is in closed alpha. Thanks for taking part! We'd love to hear what you think." />
<ErrorTrackingFilters.FilterGroup />
<LemonDivider className="mt-2" />
Expand Down Expand Up @@ -160,3 +174,63 @@ const AssigneeColumn: QueryContextColumnComponent = (props) => {
</div>
)
}

const Header = (): JSX.Element => {
const { setIsConfigurationModalOpen } = useActions(errorTrackingSceneLogic)

return (
<PageHeader
buttons={
<LemonButton type="secondary" icon={<IconGear />} onClick={() => setIsConfigurationModalOpen(true)}>
Configure
</LemonButton>
}
/>
)
}

const ConfigurationModal = (): JSX.Element => {
const { isConfigurationModalOpen, isUploadSourceMapSubmitting } = useValues(errorTrackingSceneLogic)
const { setIsConfigurationModalOpen } = useActions(errorTrackingSceneLogic)

return (
<LemonModal
title=""
onClose={() => setIsConfigurationModalOpen(false)}
isOpen={isConfigurationModalOpen}
simple
>
<Form logic={errorTrackingSceneLogic} formKey="uploadSourceMap" className="gap-1" enableFormOnSubmit>
<LemonModal.Header>
<h3>Upload source map</h3>
</LemonModal.Header>
<LemonModal.Content className="space-y-2">
<LemonField name="files">
<LemonFileInput
accept="text/plain"
multiple={false}
callToAction={
<div className="flex flex-col items-center justify-center space-y-2 border border-dashed rounded p-4">
<span className="flex items-center gap-2 font-semibold">
<IconUploadFile className="text-2xl" /> Add source map
</span>
<div>
Drag and drop your local source map here or click to open the file browser.
</div>
</div>
}
/>
</LemonField>
</LemonModal.Content>
<LemonModal.Footer>
<LemonButton type="secondary" onClick={() => setIsConfigurationModalOpen(false)}>
Cancel
</LemonButton>
<LemonButton type="primary" status="alt" htmlType="submit" loading={isUploadSourceMapSubmitting}>
Upload
</LemonButton>
</LemonModal.Footer>
</Form>
</LemonModal>
)
}
26 changes: 26 additions & 0 deletions frontend/src/scenes/error-tracking/errorTrackingSceneLogic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { lemonToast } from '@posthog/lemon-ui'
import { actions, connect, kea, path, reducers, selectors } from 'kea'
import { forms } from 'kea-forms'
import { subscriptions } from 'kea-subscriptions'
import api from 'lib/api'

import { DataTableNode, ErrorTrackingQuery } from '~/queries/schema'

Expand All @@ -19,6 +22,7 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([

actions({
setOrder: (order: ErrorTrackingQuery['order']) => ({ order }),
setIsConfigurationModalOpen: (open: boolean) => ({ open }),
setSelectedRowIndexes: (ids: number[]) => ({ ids }),
}),
reducers({
Expand All @@ -29,6 +33,12 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([
setOrder: (_, { order }) => order,
},
],
isConfigurationModalOpen: [
false as boolean,
{
setIsConfigurationModalOpen: (_, { open }) => open,
},
],
selectedRowIndexes: [
[] as number[],
{
Expand Down Expand Up @@ -72,4 +82,20 @@ export const errorTrackingSceneLogic = kea<errorTrackingSceneLogicType>([
subscriptions(({ actions }) => ({
query: () => actions.setSelectedRowIndexes([]),
})),

forms(({ actions }) => ({
uploadSourceMap: {
defaults: { files: [] } as { files: File[] },
submit: async ({ files }) => {
if (files.length > 0) {
const formData = new FormData()
const file = files[0]
formData.append('source_map', file)
await api.errorTracking.uploadSourceMaps(formData)
actions.setIsConfigurationModalOpen(false)
lemonToast.success('Source map uploaded')
}
},
},
})),
])
46 changes: 41 additions & 5 deletions posthog/api/error_tracking.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import json
import structlog

from rest_framework import serializers, viewsets, status
from rest_framework.response import Response
from rest_framework.exceptions import ValidationError

from django.db.models import QuerySet
from rest_framework import serializers, viewsets
from django.conf import settings
from django.utils.http import urlsafe_base64_decode

from posthog.api.forbid_destroy_model import ForbidDestroyModel
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.models.error_tracking import ErrorTrackingGroup
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.utils import action
from rest_framework.response import Response
from django.utils.http import urlsafe_base64_decode
import json
from posthog.storage import object_storage

FIFTY_MEGABYTES = 50 * 1024 * 1024

logger = structlog.get_logger(__name__)


class ObjectStorageUnavailable(Exception):
pass


class ErrorTrackingGroupSerializer(serializers.ModelSerializer):
Expand All @@ -33,3 +47,25 @@ def merge(self, request, **kwargs):
merging_fingerprints: list[list[str]] = request.data.get("merging_fingerprints", [])
group.merge(merging_fingerprints)
return Response({"success": True})

@action(methods=["POST"], detail=False)
def upload_source_maps(self, request, **kwargs):
try:
if settings.OBJECT_STORAGE_ENABLED:
file = request.FILES["source_map"]
if file.size > FIFTY_MEGABYTES:
raise ValidationError(code="file_too_large", detail="Source maps must be less than 50MB")

upload_path = (
f"{settings.OBJECT_STORAGE_ERROR_TRACKING_SOURCE_MAPS_FOLDER}/team-{self.team_id}/{file.name}"
)

object_storage.write(upload_path, file)
return Response({"ok": True}, status=status.HTTP_201_CREATED)
else:
raise ObjectStorageUnavailable()
except ObjectStorageUnavailable:
raise ValidationError(
code="object_storage_required",
detail="Object storage must be available to allow source map uploads.",
)
17 changes: 17 additions & 0 deletions posthog/api/test/fixtures/source.js.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 74 additions & 2 deletions posthog/api/test/test_error_tracking.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,43 @@
import os
import json
from boto3 import resource
from rest_framework import status

from django.utils.http import urlsafe_base64_encode
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings

from posthog.test.base import APIBaseTest
from posthog.models import Team, ErrorTrackingGroup
from django.utils.http import urlsafe_base64_encode
import json
from botocore.config import Config
from posthog.settings import (
OBJECT_STORAGE_ENDPOINT,
OBJECT_STORAGE_ACCESS_KEY_ID,
OBJECT_STORAGE_SECRET_ACCESS_KEY,
OBJECT_STORAGE_BUCKET,
)

TEST_BUCKET = "test_storage_bucket-TestErrorTracking"


def get_path_to(fixture_file: str) -> str:
file_dir = os.path.dirname(__file__)
return os.path.join(file_dir, "fixtures", fixture_file)


class TestErrorTracking(APIBaseTest):
def teardown_method(self, method) -> None:
s3 = resource(
"s3",
endpoint_url=OBJECT_STORAGE_ENDPOINT,
aws_access_key_id=OBJECT_STORAGE_ACCESS_KEY_ID,
aws_secret_access_key=OBJECT_STORAGE_SECRET_ACCESS_KEY,
config=Config(signature_version="s3v4"),
region_name="us-east-1",
)
bucket = s3.Bucket(OBJECT_STORAGE_BUCKET)
bucket.objects.filter(Prefix=TEST_BUCKET).delete()

def send_request(self, fingerprint, data, endpoint=""):
base64_fingerprint = urlsafe_base64_encode(json.dumps(fingerprint).encode("utf-8"))
request_method = self.client.patch if endpoint == "" else self.client.post
Expand Down Expand Up @@ -58,3 +91,42 @@ def test_merging_when_no_group_exists(self):
self.assertEqual(ErrorTrackingGroup.objects.count(), 1)
groups = ErrorTrackingGroup.objects.only("merged_fingerprints")
self.assertEqual(groups[0].merged_fingerprints, merging_fingerprints)

def test_can_upload_a_source_map(self) -> None:
with self.settings(OBJECT_STORAGE_ENABLED=True, OBJECT_STORAGE_ERROR_TRACKING_SOURCE_MAPS_FOLDER=TEST_BUCKET):
with open(get_path_to("source.js.map"), "rb") as image:
response = self.client.post(
f"/api/projects/{self.team.id}/error_tracking/upload_source_maps",
{"source_map": image},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.json())

def test_rejects_too_large_file_type(self) -> None:
fifty_megabytes_plus_a_little = b"1" * (50 * 1024 * 1024 + 1)
fake_big_file = SimpleUploadedFile(
name="large_source.js.map",
content=fifty_megabytes_plus_a_little,
content_type="text/plain",
)
response = self.client.post(
f"/api/projects/{self.team.id}/error_tracking/upload_source_maps",
{"source_map": fake_big_file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json())
self.assertEqual(response.json()["detail"], "Source maps must be less than 50MB")

def test_rejects_upload_when_object_storage_is_unavailable(self) -> None:
with override_settings(OBJECT_STORAGE_ENABLED=False):
fake_big_file = SimpleUploadedFile(name="large_source.js.map", content=b"", content_type="text/plain")
response = self.client.post(
f"/api/projects/{self.team.id}/error_tracking/upload_source_maps",
{"source_map": fake_big_file},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST, response.json())
self.assertEqual(
response.json()["detail"],
"Object storage must be available to allow source map uploads.",
)
3 changes: 3 additions & 0 deletions posthog/settings/object_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,6 @@
)
OBJECT_STORAGE_EXPORTS_FOLDER = os.getenv("OBJECT_STORAGE_EXPORTS_FOLDER", "exports")
OBJECT_STORAGE_MEDIA_UPLOADS_FOLDER = os.getenv("OBJECT_STORAGE_MEDIA_UPLOADS_FOLDER", "media_uploads")
OBJECT_STORAGE_ERROR_TRACKING_SOURCE_MAPS_FOLDER = os.getenv(
"OBJECT_STORAGE_ERROR_TRACKING_SOURCE_MAPS_FOLDER", "error_tracking_source_maps"
)
Loading