devbench 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devbench/__init__.py +121 -0
- devbench/__main__.py +24 -0
- devbench/_core.py +252 -0
- devbench/_integration.py +107 -0
- devbench/asgi.py +82 -0
- devbench/celery.py +152 -0
- devbench/config.py +189 -0
- devbench/context.py +94 -0
- devbench/direct.py +639 -0
- devbench/django/__init__.py +12 -0
- devbench/django/apps.py +68 -0
- devbench/django/management/__init__.py +0 -0
- devbench/django/management/commands/__init__.py +0 -0
- devbench/django/management/commands/devbench_test.py +18 -0
- devbench/django/middleware.py +87 -0
- devbench/egress_policy.py +101 -0
- devbench/fastapi.py +14 -0
- devbench/fingerprint.py +199 -0
- devbench/flask.py +54 -0
- devbench/identity.py +50 -0
- devbench/logs.py +282 -0
- devbench/py.typed +0 -0
- devbench/reporter.py +233 -0
- devbench/scrub.py +247 -0
- devbench/trace.py +82 -0
- devbench/version.py +12 -0
- devbench/wsgi.py +67 -0
- devbench-0.1.0.dist-info/METADATA +259 -0
- devbench-0.1.0.dist-info/RECORD +32 -0
- devbench-0.1.0.dist-info/WHEEL +5 -0
- devbench-0.1.0.dist-info/licenses/LICENSE +21 -0
- devbench-0.1.0.dist-info/top_level.txt +1 -0
devbench/django/apps.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from django.apps import AppConfig
|
|
8
|
+
|
|
9
|
+
MIDDLEWARE_PATH = "devbench.django.middleware.DevbenchMiddleware"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DevbenchConfig(AppConfig):
|
|
13
|
+
name = "devbench.django"
|
|
14
|
+
label = "devbench"
|
|
15
|
+
verbose_name = "Dev Bench"
|
|
16
|
+
default = True
|
|
17
|
+
|
|
18
|
+
def ready(self) -> None:
|
|
19
|
+
# A diagnostics package that can fail a boot is worse than none.
|
|
20
|
+
try:
|
|
21
|
+
from .. import _core, _integration
|
|
22
|
+
|
|
23
|
+
if not _core.enabled():
|
|
24
|
+
return
|
|
25
|
+
from django.conf import settings
|
|
26
|
+
|
|
27
|
+
_integration.setup(service=_project_name(settings), root=_base_dir(settings))
|
|
28
|
+
insert_middleware(settings)
|
|
29
|
+
|
|
30
|
+
from django.core.signals import got_request_exception
|
|
31
|
+
|
|
32
|
+
from .middleware import on_request_exception
|
|
33
|
+
|
|
34
|
+
got_request_exception.connect(on_request_exception, dispatch_uid="devbench.request_exception")
|
|
35
|
+
|
|
36
|
+
# The standard Celery layout imports the Celery app from the
|
|
37
|
+
# project package, so it is loaded by now, in the web process and
|
|
38
|
+
# the worker alike.
|
|
39
|
+
if "celery" in sys.modules:
|
|
40
|
+
from .. import celery as devbench_celery
|
|
41
|
+
|
|
42
|
+
devbench_celery.install()
|
|
43
|
+
except Exception:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def insert_middleware(settings: Any) -> bool:
|
|
48
|
+
"""Puts DevbenchMiddleware first, as sentry-sdk instruments the handler,
|
|
49
|
+
so ``"devbench.django"`` in INSTALLED_APPS is the whole install. Not when
|
|
50
|
+
the app already lists it. Runs in ready(), before Django's handler reads
|
|
51
|
+
MIDDLEWARE. Returns True when it was added."""
|
|
52
|
+
current = list(getattr(settings, "MIDDLEWARE", None) or [])
|
|
53
|
+
if MIDDLEWARE_PATH in current:
|
|
54
|
+
return False
|
|
55
|
+
settings.MIDDLEWARE = [MIDDLEWARE_PATH] + current
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _project_name(settings: Any) -> Optional[str]:
|
|
60
|
+
# DJANGO_SETTINGS_MODULE "acme_shop.settings" -> "acme_shop".
|
|
61
|
+
module = getattr(settings, "SETTINGS_MODULE", None) or os.environ.get("DJANGO_SETTINGS_MODULE") or ""
|
|
62
|
+
head = module.split(".")[0]
|
|
63
|
+
return head if "." in module else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _base_dir(settings: Any) -> Optional[str]:
|
|
67
|
+
base = getattr(settings, "BASE_DIR", None)
|
|
68
|
+
return str(base) if base else None
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""``python manage.py devbench_test``: ``python -m devbench test`` with the
|
|
2
|
+
project's settings loaded, so the service name is the project's."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from django.core.management.base import BaseCommand, CommandError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Command(BaseCommand):
|
|
12
|
+
help = "Check DEVBENCH_DSN against Dev Bench and print the HTTP result"
|
|
13
|
+
|
|
14
|
+
def handle(self, *args: Any, **options: Any) -> None:
|
|
15
|
+
from devbench import _core
|
|
16
|
+
|
|
17
|
+
if not _core.self_test(self.stdout):
|
|
18
|
+
raise CommandError("Dev Bench test failed (see above)")
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""DevbenchMiddleware for Django (sync and async). Added first in MIDDLEWARE
|
|
2
|
+
by the app config.
|
|
3
|
+
|
|
4
|
+
Django turns every exception inside the stack into a response before it
|
|
5
|
+
reaches this middleware, so failures are reported from
|
|
6
|
+
``got_request_exception``, which Django sends for exactly the exceptions it
|
|
7
|
+
answers with a 500 (an Http404, PermissionDenied, BadRequest or
|
|
8
|
+
SuspiciousOperation is a response, not a failure, and never reaches it). The
|
|
9
|
+
signal runs inside this middleware's scope, so the report carries the
|
|
10
|
+
request's trace and user.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any, Callable, Optional
|
|
17
|
+
|
|
18
|
+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
|
|
19
|
+
|
|
20
|
+
from .. import _core, _integration, context
|
|
21
|
+
from ..trace import WSGI_HEADER, Trace
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class DevbenchMiddleware:
|
|
25
|
+
sync_capable = True
|
|
26
|
+
async_capable = True
|
|
27
|
+
|
|
28
|
+
def __init__(self, get_response: Callable[..., Any]) -> None:
|
|
29
|
+
self.get_response = get_response
|
|
30
|
+
self._async = iscoroutinefunction(get_response)
|
|
31
|
+
if self._async:
|
|
32
|
+
markcoroutinefunction(self)
|
|
33
|
+
|
|
34
|
+
def __call__(self, request: Any) -> Any:
|
|
35
|
+
if self._async:
|
|
36
|
+
return self.__acall__(request)
|
|
37
|
+
if not _core.enabled():
|
|
38
|
+
return self.get_response(request)
|
|
39
|
+
with context.scoped(_parse(request)):
|
|
40
|
+
response = self.get_response(request)
|
|
41
|
+
_mark_handled(response)
|
|
42
|
+
return response
|
|
43
|
+
|
|
44
|
+
async def __acall__(self, request: Any) -> Any:
|
|
45
|
+
if not _core.enabled():
|
|
46
|
+
return await self.get_response(request)
|
|
47
|
+
with context.scoped(_parse(request)):
|
|
48
|
+
response = await self.get_response(request)
|
|
49
|
+
_mark_handled(response)
|
|
50
|
+
return response
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def on_request_exception(sender: Any = None, request: Any = None, **_: Any) -> None:
|
|
54
|
+
"""got_request_exception: the exception being handled is sys.exc_info()."""
|
|
55
|
+
try:
|
|
56
|
+
_integration.report_request_error(sys.exc_info()[1], _symbol(request))
|
|
57
|
+
except Exception:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _parse(request: Any) -> Optional[Trace]:
|
|
62
|
+
try:
|
|
63
|
+
return Trace.parse(request.META.get(WSGI_HEADER))
|
|
64
|
+
except Exception:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _symbol(request: Any) -> Optional[str]:
|
|
69
|
+
# "shop.views.checkout": the resolved view, when routing got that far.
|
|
70
|
+
try:
|
|
71
|
+
match = getattr(request, "resolver_match", None)
|
|
72
|
+
if match is None:
|
|
73
|
+
return None
|
|
74
|
+
return getattr(match, "_func_path", None) or _integration.callable_path(match.func)
|
|
75
|
+
except Exception:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _mark_handled(response: Any) -> None:
|
|
80
|
+
try:
|
|
81
|
+
count = _integration.handled_count()
|
|
82
|
+
if count <= 0 or response is None:
|
|
83
|
+
return
|
|
84
|
+
response[_integration.HANDLED_HEADER] = str(count)
|
|
85
|
+
response[_integration.EXPOSE_HEADER] = _integration.exposed(response.get(_integration.EXPOSE_HEADER))
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Learned redaction rules, as ingest sends them on a flush response
|
|
2
|
+
(``redaction``), compiled and applied exactly as the sidecar does
|
|
3
|
+
(internal/sidecar/rules.go and Sidecar.redactBody)::
|
|
4
|
+
|
|
5
|
+
{"fp": <shape fp>, "egress": "masked"|"none"|"full"} shape rule
|
|
6
|
+
{"kind": "field", "target": "license"} mask that field's value
|
|
7
|
+
{"kind": "term", "target": "Whitfield"} mask that literal
|
|
8
|
+
|
|
9
|
+
Immutable once built; the transport swaps a whole new one in when a response
|
|
10
|
+
carries a rule set, and keeps the current one when a response carries none
|
|
11
|
+
(absent is not empty: ingest omits the field when it could not look the
|
|
12
|
+
rules up).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from .fingerprint import GO_WS as WS
|
|
21
|
+
|
|
22
|
+
FIELD = "field"
|
|
23
|
+
TERM = "term"
|
|
24
|
+
|
|
25
|
+
# store.NormalizeRule's field-name shape; anything else is ignored, so a
|
|
26
|
+
# malformed rule never becomes an odd pattern here.
|
|
27
|
+
FIELD_NAME = re.compile(r"[a-z0-9_.\-]{1,64}\Z")
|
|
28
|
+
# A shorter term would mask fragments of ordinary words everywhere.
|
|
29
|
+
MIN_TERM_BYTES = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EgressPolicy:
|
|
33
|
+
__slots__ = ("_shapes", "_terms", "_fields")
|
|
34
|
+
|
|
35
|
+
def __init__(self, shapes: Dict[str, str], fields: List[str], terms: List[str]) -> None:
|
|
36
|
+
self._shapes = dict(shapes)
|
|
37
|
+
self._terms = tuple(terms)
|
|
38
|
+
self._fields = _field_pattern(fields) if fields else None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def compile(cls, rules: Any) -> "EgressPolicy":
|
|
42
|
+
"""rules: the parsed JSON array. Entries that are not objects, or not
|
|
43
|
+
understood, are skipped (a newer ingest may send kinds this SDK cannot
|
|
44
|
+
enforce; the local passes still run)."""
|
|
45
|
+
shapes: Dict[str, str] = {}
|
|
46
|
+
fields: List[str] = []
|
|
47
|
+
terms: List[str] = []
|
|
48
|
+
for rule in rules if isinstance(rules, list) else []:
|
|
49
|
+
if not isinstance(rule, dict):
|
|
50
|
+
continue
|
|
51
|
+
kind = rule.get("kind")
|
|
52
|
+
kind = kind if isinstance(kind, str) else ""
|
|
53
|
+
target = rule.get("target")
|
|
54
|
+
target = target if isinstance(target, str) else ""
|
|
55
|
+
if kind == "":
|
|
56
|
+
fp = rule.get("fp")
|
|
57
|
+
if isinstance(fp, str) and fp:
|
|
58
|
+
egress = rule.get("egress")
|
|
59
|
+
shapes[fp] = egress if isinstance(egress, str) else ""
|
|
60
|
+
elif kind == FIELD:
|
|
61
|
+
name = target.strip().lower()
|
|
62
|
+
if FIELD_NAME.match(name) and name not in fields:
|
|
63
|
+
fields.append(name)
|
|
64
|
+
elif kind == TERM:
|
|
65
|
+
if len(target.encode("utf-8", "replace")) >= MIN_TERM_BYTES and target not in terms:
|
|
66
|
+
terms.append(target)
|
|
67
|
+
return cls(shapes, fields, terms)
|
|
68
|
+
|
|
69
|
+
def rule_for(self, fp: Optional[str]) -> Optional[str]:
|
|
70
|
+
"""The shape rule for a fingerprint, or None."""
|
|
71
|
+
return self._shapes.get(fp) if fp else None
|
|
72
|
+
|
|
73
|
+
def has_shapes(self) -> bool:
|
|
74
|
+
return bool(self._shapes)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def terms(self) -> tuple:
|
|
78
|
+
return self._terms
|
|
79
|
+
|
|
80
|
+
def mask(self, value: str) -> str:
|
|
81
|
+
"""Field and term rules. Additive only: it removes text, never
|
|
82
|
+
restores any."""
|
|
83
|
+
if self._fields is not None:
|
|
84
|
+
value = self._fields.sub(r"\g<1>\g<2>\g<3>\g<4><redacted:field>", value)
|
|
85
|
+
for term in self._terms:
|
|
86
|
+
value = value.replace(term, "<redacted:term>")
|
|
87
|
+
return value
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _field_pattern(fields: List[str]) -> "re.Pattern[str]":
|
|
91
|
+
"""The field name, exactly ("license" must not catch "licensed" or
|
|
92
|
+
"driver_license"), optionally quoted or a Ruby symbol, then a separator,
|
|
93
|
+
then one value: a quoted string or a bare token. Go's pattern, with Go's
|
|
94
|
+
\\s and with \\A for Go's unanchored-mode ^."""
|
|
95
|
+
names = "|".join(re.escape(f) for f in fields)
|
|
96
|
+
return re.compile(
|
|
97
|
+
r"(\A|[^a-z0-9_.\-])([\"':]?)(" + names + ")"
|
|
98
|
+
r"([\"']?" + WS + "*(?:=>|=|:)" + WS + "*)"
|
|
99
|
+
r"(\"(?:[^\"\\]|\\.)*\"|'(?:[^'\\]|\\.)*'|[^\t\n\f\r ,;&)}\]]+)",
|
|
100
|
+
re.IGNORECASE,
|
|
101
|
+
)
|
devbench/fastapi.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""FastAPI (and Starlette)::
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from devbench.fastapi import DevbenchMiddleware
|
|
5
|
+
|
|
6
|
+
app = FastAPI()
|
|
7
|
+
app.add_middleware(DevbenchMiddleware)
|
|
8
|
+
|
|
9
|
+
The same middleware as devbench.asgi.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .asgi import DevbenchMiddleware
|
|
13
|
+
|
|
14
|
+
__all__ = ["DevbenchMiddleware"]
|
devbench/fingerprint.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""A port of internal/fingerprint (Go): the same signal gives the same
|
|
2
|
+
fingerprint, byte for byte, as the sidecar, the browser, Ruby and Go compute.
|
|
3
|
+
|
|
4
|
+
If two implementations disagree, the same problem is counted as two: triage
|
|
5
|
+
cost doubles and one issue is split in half. testdata/fingerprint_vectors.json
|
|
6
|
+
holds all of them to one answer (tests/test_vectors.py).
|
|
7
|
+
|
|
8
|
+
Go's regexp and Python's differ in ways that matter here, so the patterns are
|
|
9
|
+
spelled out rather than copied:
|
|
10
|
+
|
|
11
|
+
* Go's ``\\b`` is an ASCII word boundary; Python's (str patterns) treats "é"
|
|
12
|
+
as a word character. ``GO_B`` rebuilds Go's.
|
|
13
|
+
* Go's ``\\d`` is ASCII; Python's matches every Unicode digit. Spelled
|
|
14
|
+
``[0-9]``.
|
|
15
|
+
* Go's ``\\s`` is ``[\\t\\n\\f\\r ]``; Python's adds ``\\v`` and Unicode
|
|
16
|
+
spaces. ``GO_WS`` is Go's.
|
|
17
|
+
* Go's strings.TrimSpace trims Unicode spaces (unicode.IsSpace), a set that
|
|
18
|
+
differs from str.strip()'s. ``GO_SPACE`` is Go's.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import re
|
|
25
|
+
from typing import Any, Iterable, Mapping
|
|
26
|
+
|
|
27
|
+
GO_W = "[0-9A-Za-z_]"
|
|
28
|
+
GO_B = "(?:(?<=%s)(?!%s)|(?<!%s)(?=%s))" % (GO_W, GO_W, GO_W, GO_W)
|
|
29
|
+
GO_WS = r"[\t\n\f\r ]"
|
|
30
|
+
GO_NOT_WS = r"[^\t\n\f\r ]"
|
|
31
|
+
# unicode.IsSpace, which strings.TrimSpace and strings.Fields use.
|
|
32
|
+
GO_SPACE = "\t\n\v\f\r \u0085 " + "".join(chr(c) for c in range(0x2000, 0x200B)) + "
"
|
|
33
|
+
GO_SPACE_CLASS = "\t\n\v\f\r \u0085 -
"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def go_re(source: str, flags: int = 0) -> "re.Pattern[str]":
|
|
37
|
+
"""Compiles a Go pattern: ``\\d`` (outside character classes) as ASCII
|
|
38
|
+
digits and ``\\b`` as Go's ASCII word boundary."""
|
|
39
|
+
return re.compile(source.replace(r"\d", "[0-9]").replace(r"\b", GO_B), flags)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Ordered as in Go: more specific patterns first, or a UUID becomes a string
|
|
43
|
+
# of <num> fragments.
|
|
44
|
+
TEMPLATE_RULES = [
|
|
45
|
+
(go_re(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", re.IGNORECASE), "<uuid>"),
|
|
46
|
+
(go_re(r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?"), "<ts>"),
|
|
47
|
+
(go_re(r"\b\d{4}-\d{2}-\d{2}\b"), "<date>"),
|
|
48
|
+
(go_re(r"\b[^\t\n\f\r <>@]+@[^\t\n\f\r <>@]+\.[A-Za-z]{2,}\b"), "<email>"),
|
|
49
|
+
(go_re(r"\b[a-z][a-z0-9+.-]*://" + GO_NOT_WS + "+"), "<url>"),
|
|
50
|
+
(go_re(r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"), "<ip>"),
|
|
51
|
+
(go_re(r"\b(?:0x)?[0-9a-fA-F]{8,}\b"), "<hex>"),
|
|
52
|
+
(re.compile(r'"[^"]*"'), "<str>"),
|
|
53
|
+
(re.compile(r"'[^']*'"), "<str>"),
|
|
54
|
+
(go_re(r"\b\d+(?:\.\d+)?" + GO_WS + r"?(?:ms|s|us|ns|µs)\b"), "<dur>"),
|
|
55
|
+
(go_re(r"\b\d+(?:\.\d+)?\b"), "<num>"),
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
WHITESPACE = re.compile(GO_WS + "+")
|
|
59
|
+
MAX_FRAMES = 5
|
|
60
|
+
KEEP_SEGMENTS = 3
|
|
61
|
+
|
|
62
|
+
DEPENDENCY_FRAGMENTS = (
|
|
63
|
+
"/node_modules/", "/vendor/", "/gems/", "/ruby/gems/", "/usr/local/go/src/",
|
|
64
|
+
"/go/pkg/mod/", "/.bundle/", "<anonymous>",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
ASSET_DIGEST = re.compile(r"(.+)[-.]([A-Za-z0-9_]{8,})((?:\.chunk|\.bundle)?\.(?:js|mjs|cjs|css)(?:\.map)?)\Z")
|
|
68
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
69
|
+
_LOWER_HEX = re.compile(r"[0-9a-f]*\Z")
|
|
70
|
+
_DIGIT = re.compile("[0-9]")
|
|
71
|
+
_UPPER = re.compile("[A-Z]")
|
|
72
|
+
_QUERY = re.compile(r"[?#]")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def utf8(text: Any) -> str:
|
|
76
|
+
"""Go treats invalid UTF-8 as U+FFFD. A Python str cannot hold invalid
|
|
77
|
+
UTF-8, but it can hold lone surrogates (surrogateescape'd bytes from a
|
|
78
|
+
path or an environment variable), which cannot be encoded: each becomes
|
|
79
|
+
U+FFFD, as Go would have read the byte."""
|
|
80
|
+
if isinstance(text, bytes):
|
|
81
|
+
return text.decode("utf-8", "replace")
|
|
82
|
+
if not isinstance(text, str):
|
|
83
|
+
text = "" if text is None else str(text)
|
|
84
|
+
if _SURROGATE.search(text):
|
|
85
|
+
return _SURROGATE.sub("�", text)
|
|
86
|
+
return text
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def trim(text: str) -> str:
|
|
90
|
+
return text.strip(GO_SPACE)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def compute(kind: str, source: str, service: str = "", type: str = "", message: str = "",
|
|
94
|
+
frames: Iterable[Mapping[str, Any]] | None = None) -> str | None:
|
|
95
|
+
"""The fingerprint (64 hex chars) of a signal, or None when the signal has
|
|
96
|
+
no kind/source, or no type, message or frames (Go returns an error)."""
|
|
97
|
+
frames = list(frames or [])
|
|
98
|
+
if not kind or not source:
|
|
99
|
+
return None
|
|
100
|
+
if not type and not message and not frames:
|
|
101
|
+
return None
|
|
102
|
+
norm = normalize(kind, source, service, type, message, frames)
|
|
103
|
+
return hashlib.sha256(canonical(norm).encode("utf-8")).hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def normalize(kind: str, source: str, service: str, type: str, message: str,
|
|
107
|
+
frames: Iterable[Mapping[str, Any]] | None) -> dict:
|
|
108
|
+
"""The canonical form, field by field, as Go's Normalized."""
|
|
109
|
+
return {
|
|
110
|
+
"kind": utf8(kind),
|
|
111
|
+
"source": utf8(source),
|
|
112
|
+
"service": utf8(service),
|
|
113
|
+
"type": trim(utf8(type)),
|
|
114
|
+
"template": template(message),
|
|
115
|
+
"frames": _normalize_frames(frames or []),
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def template(message: Any) -> str:
|
|
120
|
+
"""Reduces a message to its shape, replacing values that vary between
|
|
121
|
+
occurrences with holes."""
|
|
122
|
+
out = trim(utf8(message))
|
|
123
|
+
if not out:
|
|
124
|
+
return ""
|
|
125
|
+
for pattern, hole in TEMPLATE_RULES:
|
|
126
|
+
out = pattern.sub(hole, out)
|
|
127
|
+
return trim(WHITESPACE.sub(" ", out))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def normalize_path(path: Any) -> str:
|
|
131
|
+
p = trim(utf8(path))
|
|
132
|
+
if not p:
|
|
133
|
+
return ""
|
|
134
|
+
i = p.find("://")
|
|
135
|
+
if i >= 0:
|
|
136
|
+
rest = p[i + 3:]
|
|
137
|
+
j = rest.find("/")
|
|
138
|
+
p = rest[j:] if j >= 0 else rest
|
|
139
|
+
m = _QUERY.search(p)
|
|
140
|
+
if m:
|
|
141
|
+
p = p[: m.start()]
|
|
142
|
+
p = p.replace("\\", "/")
|
|
143
|
+
segments = p.strip("/").split("/")
|
|
144
|
+
segments = segments[-KEEP_SEGMENTS:]
|
|
145
|
+
segments[-1] = _strip_asset_digest(segments[-1])
|
|
146
|
+
return "/".join(segments)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def canonical(norm: Mapping[str, Any]) -> str:
|
|
150
|
+
return "\x1e".join([
|
|
151
|
+
"v1", norm["kind"], norm["source"], norm["service"], norm["type"], norm["template"],
|
|
152
|
+
"\x1f".join(norm["frames"]),
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_frames(frames: Iterable[Mapping[str, Any]]) -> list:
|
|
157
|
+
out = []
|
|
158
|
+
for f in frames:
|
|
159
|
+
function = _field(f, "function")
|
|
160
|
+
file = _field(f, "file")
|
|
161
|
+
if _dependency(file, function):
|
|
162
|
+
continue
|
|
163
|
+
fn = trim(function)
|
|
164
|
+
path = normalize_path(file)
|
|
165
|
+
if not fn and not path:
|
|
166
|
+
continue
|
|
167
|
+
# Deliberately no line number.
|
|
168
|
+
out.append(fn + "@" + path)
|
|
169
|
+
if len(out) == MAX_FRAMES:
|
|
170
|
+
break
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _field(frame: Any, name: str) -> str:
|
|
175
|
+
if not isinstance(frame, Mapping):
|
|
176
|
+
return ""
|
|
177
|
+
value = frame.get(name)
|
|
178
|
+
return "" if value is None else utf8(value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _dependency(file: str, function: str) -> bool:
|
|
182
|
+
hay = file + " " + function
|
|
183
|
+
return any(frag in hay for frag in DEPENDENCY_FRAGMENTS)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _strip_asset_digest(name: str) -> str:
|
|
187
|
+
m = ASSET_DIGEST.match(name)
|
|
188
|
+
if m is None or not _digest(m.group(2)):
|
|
189
|
+
return name
|
|
190
|
+
return m.group(1) + m.group(3)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _digest(token: str) -> bool:
|
|
194
|
+
digit = _DIGIT.search(token) is not None
|
|
195
|
+
upper = _UPPER.search(token) is not None
|
|
196
|
+
hex_only = _LOWER_HEX.match(token) is not None
|
|
197
|
+
if hex_only and digit and len(token) >= 8:
|
|
198
|
+
return True
|
|
199
|
+
return len(token) == 8 and digit and upper
|
devbench/flask.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Flask::
|
|
2
|
+
|
|
3
|
+
from flask import Flask
|
|
4
|
+
from devbench.flask import Devbench
|
|
5
|
+
|
|
6
|
+
app = Flask(__name__)
|
|
7
|
+
Devbench(app) # or devbench_ext = Devbench(); devbench_ext.init_app(app)
|
|
8
|
+
|
|
9
|
+
Wraps ``app.wsgi_app`` in the WSGI middleware (trace, identity scope,
|
|
10
|
+
``x-adt-handled``) and subscribes to ``got_request_exception``, which Flask
|
|
11
|
+
sends for an unhandled exception before it answers 500 (an ``abort(404)`` or
|
|
12
|
+
any other HTTPException is a response, not a failure, and never reaches it).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Optional
|
|
18
|
+
|
|
19
|
+
from flask import got_request_exception, request
|
|
20
|
+
|
|
21
|
+
from . import _integration
|
|
22
|
+
from .wsgi import DevbenchMiddleware
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Devbench:
|
|
26
|
+
def __init__(self, app: Any = None) -> None:
|
|
27
|
+
if app is not None:
|
|
28
|
+
self.init_app(app)
|
|
29
|
+
|
|
30
|
+
def init_app(self, app: Any) -> None:
|
|
31
|
+
extensions = getattr(app, "extensions", None)
|
|
32
|
+
if isinstance(extensions, dict):
|
|
33
|
+
if "devbench" in extensions:
|
|
34
|
+
return
|
|
35
|
+
extensions["devbench"] = self
|
|
36
|
+
_integration.setup(service=getattr(app, "import_name", None))
|
|
37
|
+
app.wsgi_app = DevbenchMiddleware(app.wsgi_app)
|
|
38
|
+
got_request_exception.connect(_on_exception, app, weak=False)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _on_exception(sender: Any, exception: Optional[BaseException] = None, **_: Any) -> None:
|
|
42
|
+
try:
|
|
43
|
+
_integration.report_request_error(exception, _symbol(sender))
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _symbol(app: Any) -> Optional[str]:
|
|
49
|
+
try:
|
|
50
|
+
endpoint = request.endpoint
|
|
51
|
+
view = app.view_functions.get(endpoint) if endpoint else None
|
|
52
|
+
return _integration.callable_path(view) if view is not None else endpoint
|
|
53
|
+
except Exception:
|
|
54
|
+
return None
|
devbench/identity.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Who was affected (docs/SERVER_SDK_SPEC.md, Capability 5).
|
|
2
|
+
|
|
3
|
+
Identity travels in its own field and nowhere else: never in a message, a
|
|
4
|
+
symbol or a log line, so it can never move a fingerprint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
from . import context
|
|
12
|
+
from .fingerprint import utf8
|
|
13
|
+
|
|
14
|
+
MAX = 255
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize(email: Any, account: Any) -> Optional[dict]:
|
|
18
|
+
"""``{"email", "account"}`` with empty fields dropped (email trimmed and
|
|
19
|
+
lowercased, each at most 255 characters), or None when neither is
|
|
20
|
+
present."""
|
|
21
|
+
user = {}
|
|
22
|
+
e = _text(email).strip().lower()
|
|
23
|
+
if e:
|
|
24
|
+
user["email"] = e[:MAX]
|
|
25
|
+
a = _text(account).strip()
|
|
26
|
+
if a:
|
|
27
|
+
user["account"] = a[:MAX]
|
|
28
|
+
return user or None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_user(email: Any = None, account: Any = None) -> None:
|
|
32
|
+
"""Records who the current request (or job) is for. Call once per
|
|
33
|
+
request, e.g. from middleware after authentication; the Dev Bench
|
|
34
|
+
middleware ends it with the request. Each field is optional; calling
|
|
35
|
+
again replaces the whole identity, and calling with neither clears it.
|
|
36
|
+
Never raises."""
|
|
37
|
+
try:
|
|
38
|
+
context.ensure().user = normalize(email, account)
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _text(value: Any) -> str:
|
|
44
|
+
# An identity that cannot be read is dropped rather than raised.
|
|
45
|
+
if value is None:
|
|
46
|
+
return ""
|
|
47
|
+
try:
|
|
48
|
+
return utf8(value)
|
|
49
|
+
except Exception:
|
|
50
|
+
return ""
|