Last time's work: we can get something on-screen now

This commit is contained in:
Klaas van Schelven
2023-11-03 19:25:26 +01:00
parent abccf2c553
commit bfea1b30cc
23 changed files with 356 additions and 31 deletions

8
.gitignore vendored
View File

@@ -1,3 +1,7 @@
# Python
*.pyc
__pycache__
# Virtual Environments
.env
.venv
@@ -7,3 +11,7 @@ lib/
lib64
pyvenv.cfg
share/
# sqlite
db.sqlite3
db.*.sqlite3

0
ingest/__init__.py Normal file
View File

3
ingest/admin.py Normal file
View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
ingest/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class IngestConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'ingest'

View File

3
ingest/models.py Normal file
View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

18
ingest/negotiation.py Normal file
View File

@@ -0,0 +1,18 @@
from rest_framework.negotiation import BaseContentNegotiation
class IgnoreClientContentNegotiation(BaseContentNegotiation):
# Sentry clients do not always send the correct Accept headers but expect json anyway.
# Proceed as per https://www.django-rest-framework.org/api-guide/content-negotiation/#example
def select_parser(self, request, parsers):
"""
Select the first parser in the `.parser_classes` list.
"""
return parsers[0]
def select_renderer(self, request, renderers, format_suffix):
"""
Select the first renderer in the `.renderer_classes` list.
"""
return (renderers[0], renderers[0].media_type)

28
ingest/parsers.py Normal file
View File

@@ -0,0 +1,28 @@
import codecs
from django.conf import settings
from rest_framework.exceptions import ParseError
from rest_framework.parsers import JSONParser
from rest_framework.utils import json
class EnvelopeParser(JSONParser):
media_type = "application/x-sentry-envelope"
def parse(self, stream, media_type=None, parser_context=None):
"""
Parses the incoming bytestream as JSON and returns the resulting data.
Supports multiple lines of JSON objects
"""
parser_context = parser_context or {}
encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET)
try:
decoded_stream = codecs.getreader(encoding)(stream)
parse_constant = json.strict_constant if self.strict else None
messages = []
for line in decoded_stream:
messages.append(json.loads(line, parse_constant=parse_constant))
return messages
except ValueError as exc:
raise ParseError("JSON parse error - %s" % str(exc))

3
ingest/tests.py Normal file
View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

9
ingest/urls.py Normal file
View File

@@ -0,0 +1,9 @@
from django.urls import path
from .views import IngestEventAPIView, IngestEnvelopeAPIView
urlpatterns = [
# project_id has to be an int per Sentry Client expectations.
path("<int:project_id>/store/", IngestEventAPIView.as_view()),
path("<int:project_id>/envelope/", IngestEnvelopeAPIView.as_view()),
]

29
ingest/views.py Normal file
View File

@@ -0,0 +1,29 @@
from rest_framework import permissions
from rest_framework.response import Response
from rest_framework.views import APIView
# from projects.models import Project
# from sentry.utils.auth import parse_auth_header
from .negotiation import IgnoreClientContentNegotiation
from .parsers import EnvelopeParser
class BaseIngestAPIView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = []
content_negotiation_class = IgnoreClientContentNegotiation
http_method_names = ["post"]
def post(self, request, *args, **kwargs):
import pdb; pdb.set_trace()
# return self.process_event(request.data, request, project)
return Response()
class IngestEventAPIView(BaseIngestAPIView):
pass
class IngestEnvelopeAPIView(BaseIngestAPIView):
parser_classes = [EnvelopeParser]

View File

@@ -1,24 +1,14 @@
"""
Django settings for project project.
Generated by 'django-admin startproject' using Django 4.2.6.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
import os
from pathlib import Path
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-$@clhhieazwnxnha-_zah&(bieq%yux7#^07&xsvhn58t)8@xw'
@@ -37,6 +27,8 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'ingest',
]
MIDDLEWARE = [
@@ -47,6 +39,8 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'sentry.middleware.proxy.DecompressBodyMiddleware',
]
ROOT_URLCONF = 'project.urls'
@@ -76,7 +70,7 @@ WSGI_APPLICATION = 'project.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
'NAME': BASE_DIR / os.getenv("DATABASE_NAME", 'db.sqlite3'),
}
}
@@ -121,3 +115,16 @@ STATIC_URL = 'static/'
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# {PROTOCOL}://{PUBLIC_KEY}:{SECRET_KEY}@{HOST}{PATH}/{PROJECT_ID}
SENTRY_DSN = os.getenv("SENTRY_DSN") # "http://ignored_public_key:ignored_secret_key@127.0.0.1:9000/1"
if SENTRY_DSN is not None:
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=[DjangoIntegration()],
auto_session_tracking=False,
traces_sample_rate=0,
)

View File

@@ -1,22 +1,18 @@
"""
URL configuration for project project.
from django.conf import settings
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from django.urls import include, path
from .views import trigger_error
urlpatterns = [
path('api/', include('ingest.urls')),
path('admin/', admin.site.urls),
]
if settings.DEBUG:
urlpatterns += [
path('trigger-error/', trigger_error),
]

2
project/views.py Normal file
View File

@@ -0,0 +1,2 @@
def trigger_error(request):
raise Exception("Exception triggered on purpose to debug error handling")

View File

@@ -1 +1,5 @@
Django==4.2.*
sentry-sdk==1.32.*
djangorestframework==3.14.*
six

0
sentry/__init__.py Normal file
View File

View File

206
sentry/middleware/proxy.py Normal file
View File

@@ -0,0 +1,206 @@
import io
import logging
import zlib
from django.conf import settings
from django.core.exceptions import RequestDataTooBig
try:
import uwsgi
has_uwsgi = True
except ImportError:
has_uwsgi = False
logger = logging.getLogger(__name__)
Z_CHUNK = 1024 * 8
if has_uwsgi:
class UWsgiChunkedInput(io.RawIOBase):
def __init__(self):
self._internal_buffer = b""
def readable(self):
return True
def readinto(self, buf):
if not self._internal_buffer:
self._internal_buffer = uwsgi.chunked_read()
n = min(len(buf), len(self._internal_buffer))
if n > 0:
buf[:n] = self._internal_buffer[:n]
self._internal_buffer = self._internal_buffer[n:]
return n
class ZDecoder(io.RawIOBase):
"""
Base class for HTTP content decoders based on zlib
See: https://github.com/eBay/wextracto/blob/9c789b1c98d95a1e87dbedfd1541a8688d128f5c/wex/http_decoder.py
"""
def __init__(self, fp, z=None):
self.fp = fp
self.z = z
self.flushed = None
self.counter = 0
def readable(self):
return True
def readinto(self, buf):
if self.z is None:
self.z = zlib.decompressobj()
retry = True
else:
retry = False
n = 0
max_length = len(buf)
# DOS mitigation - block unzipped payloads larger than max allowed size
self.counter += 1
if self.counter * max_length > 1_000_000_000_000: # TODO I replaced this
raise RequestDataTooBig()
while max_length > 0:
if self.flushed is None:
chunk = self.fp.read(Z_CHUNK)
compressed = self.z.unconsumed_tail + chunk
try:
decompressed = self.z.decompress(compressed, max_length)
except zlib.error:
if not retry:
raise
self.z = zlib.decompressobj(-zlib.MAX_WBITS)
retry = False
decompressed = self.z.decompress(compressed, max_length)
if not chunk:
self.flushed = self.z.flush()
else:
if not self.flushed:
return n
decompressed = self.flushed[:max_length]
self.flushed = self.flushed[max_length:]
buf[n:n + len(decompressed)] = decompressed
n += len(decompressed)
max_length = len(buf) - n
return n
class DeflateDecoder(ZDecoder):
"""
Decoding for "content-encoding: deflate"
"""
class GzipDecoder(ZDecoder):
"""
Decoding for "content-encoding: gzip"
"""
def __init__(self, fp):
ZDecoder.__init__(self, fp, zlib.decompressobj(16 + zlib.MAX_WBITS))
class SetRemoteAddrFromForwardedFor(object):
def __init__(self):
if not getattr(settings, "SENTRY_USE_X_FORWARDED_FOR", True):
from django.core.exceptions import MiddlewareNotUsed
raise MiddlewareNotUsed
def _remove_port_number(self, ip_address):
if "[" in ip_address and "]" in ip_address:
# IPv6 address with brackets, possibly with a port number
return ip_address[ip_address.find("[") + 1:ip_address.find("]")]
if "." in ip_address and ip_address.rfind(":") > ip_address.rfind("."):
# IPv4 address with port number
# the last condition excludes IPv4-mapped IPv6 addresses
return ip_address.rsplit(":", 1)[0]
return ip_address
def process_request(self, request):
try:
real_ip = request.META["HTTP_X_FORWARDED_FOR"]
except KeyError:
pass
else:
# HTTP_X_FORWARDED_FOR can be a comma-separated list of IPs.
# Take just the first one.
real_ip = real_ip.split(",")[0].strip()
real_ip = self._remove_port_number(real_ip)
request.META["REMOTE_ADDR"] = real_ip
class ChunkedMiddleware(object):
def __init__(self):
if not has_uwsgi:
from django.core.exceptions import MiddlewareNotUsed
raise MiddlewareNotUsed
def process_request(self, request):
# If we are dealing with chunked data and we have uwsgi we assume
# that we can read to the end of the input stream so we can bypass
# the default limited stream. We set the content length reasonably
# high so that the reads generally succeed. This is ugly but with
# Django 1.6 it seems to be the best we can easily do.
if "HTTP_TRANSFER_ENCODING" not in request.META:
return
if request.META["HTTP_TRANSFER_ENCODING"].lower() == "chunked":
request._stream = io.BufferedReader(UWsgiChunkedInput())
request.META["CONTENT_LENGTH"] = "4294967295" # 0xffffffff
class DecompressBodyMiddleware(object):
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
decode = False
encoding = request.META.get("HTTP_CONTENT_ENCODING", "").lower()
if encoding == "gzip":
request._stream = GzipDecoder(request._stream)
decode = True
if encoding == "deflate":
request._stream = DeflateDecoder(request._stream)
decode = True
if decode:
# Since we don't know the original content length ahead of time, we
# need to set the content length reasonably high so read generally
# succeeds. This seems to be the only easy way for Django 1.6.
request.META["CONTENT_LENGTH"] = "4294967295" # 0xffffffff
# The original content encoding is no longer valid, so we have to
# remove the header. Otherwise, LazyData will attempt to re-decode
# the body.
del request.META["HTTP_CONTENT_ENCODING"]
return self.get_response(request)
class ContentLengthHeaderMiddleware(object):
"""
Ensure that we have a proper Content-Length/Transfer-Encoding header
"""
def process_response(self, request, response):
if "Transfer-Encoding" in response or "Content-Length" in response:
return response
if not response.streaming:
response["Content-Length"] = str(len(response.content))
return response

3
tox.ini Normal file
View File

@@ -0,0 +1,3 @@
[flake8]
max-line-length=120
exclude=venv,.venv,migrations