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/__init__.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Dev Bench server SDK for Python.
|
|
2
|
+
|
|
3
|
+
pip install devbench
|
|
4
|
+
DEVBENCH_DSN=https://<public>:<secret>@<ingest host> (from `adt dsn create`)
|
|
5
|
+
|
|
6
|
+
then one line where your framework wants it (README): Django
|
|
7
|
+
(``"devbench.django"`` in INSTALLED_APPS), FastAPI/Starlette
|
|
8
|
+
(``app.add_middleware(DevbenchMiddleware)``), Flask (``Devbench(app)``),
|
|
9
|
+
Celery (``devbench.celery.install()``; automatic under Django).
|
|
10
|
+
|
|
11
|
+
What it does:
|
|
12
|
+
|
|
13
|
+
* accepts and forwards the correlation id (the middlewares, trace_headers)
|
|
14
|
+
* reports unhandled exceptions (the middlewares, Celery hooks,
|
|
15
|
+
capture_exception)
|
|
16
|
+
* reports handled failures (report_handled)
|
|
17
|
+
* records who was affected (set_user)
|
|
18
|
+
* keeps recent log lines per trace (capture_logs; a logging handler)
|
|
19
|
+
|
|
20
|
+
Nothing here raises into the application.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, Dict, Optional
|
|
26
|
+
|
|
27
|
+
from . import _core, context, reporter
|
|
28
|
+
from .config import DSN, Configuration, InvalidDSN
|
|
29
|
+
from .identity import set_user
|
|
30
|
+
from .trace import HEADER as TRACE_HEADER
|
|
31
|
+
from .trace import Trace
|
|
32
|
+
from .version import PROTOCOL_VERSION, VERSION
|
|
33
|
+
|
|
34
|
+
__version__ = VERSION
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"PROTOCOL_VERSION", "VERSION", "DSN", "Configuration", "InvalidDSN", "Trace", "TRACE_HEADER",
|
|
38
|
+
"configure", "enabled", "set_user", "capture_exception", "report_handled", "ignore_exceptions",
|
|
39
|
+
"trace_headers", "current_trace", "capture_logs", "flush", "test",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def configure(dsn: Optional[str] = None, service: Optional[str] = None, release: Optional[str] = None,
|
|
44
|
+
enabled: Optional[bool] = None, flush_interval: Optional[float] = None) -> None:
|
|
45
|
+
"""Sets any of the environment's values from code (only those given)::
|
|
46
|
+
|
|
47
|
+
devbench.configure(dsn=secrets["devbench_dsn"], service="billing-api")
|
|
48
|
+
|
|
49
|
+
Takes effect from the next report. Never raises."""
|
|
50
|
+
_core.configure(dsn=dsn, service=service, release=release, enabled=enabled, flush_interval=flush_interval)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def enabled() -> bool:
|
|
54
|
+
"""False when DEVBENCH_ENABLED=false (or configure(enabled=False))."""
|
|
55
|
+
return _core.enabled()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def capture_exception(error: Optional[BaseException] = None, symbol: Optional[str] = None) -> None:
|
|
59
|
+
"""Reports an exception the application caught and wants seen, as
|
|
60
|
+
Sentry's capture_exception does (the one being handled when ``error`` is
|
|
61
|
+
omitted). Returns None and never raises::
|
|
62
|
+
|
|
63
|
+
except requests.RequestException as e:
|
|
64
|
+
devbench.capture_exception(e, symbol="billing.sync")
|
|
65
|
+
"""
|
|
66
|
+
if error is None:
|
|
67
|
+
import sys
|
|
68
|
+
|
|
69
|
+
error = sys.exc_info()[1]
|
|
70
|
+
reporter.capture(error, "explicit", True, symbol)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def report_handled(error: Any, symbol: str, reason: Optional[str] = None) -> None:
|
|
74
|
+
"""Records that a failure was handled (caught and not re-raised) here.
|
|
75
|
+
Does not change behaviour. Repeats at the same site are counted::
|
|
76
|
+
|
|
77
|
+
except IntegrityError as e:
|
|
78
|
+
devbench.report_handled(e, symbol="customers.views.update", reason="uniqueness")
|
|
79
|
+
"""
|
|
80
|
+
reporter.report_handled(error, symbol, reason)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ignore_exceptions() -> list:
|
|
84
|
+
"""Class names (``module.QualName``) never reported; append your own."""
|
|
85
|
+
return reporter.ignore_exceptions()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def current_trace() -> Optional[Trace]:
|
|
89
|
+
"""The x-adt-trace of the request or job being handled, or None."""
|
|
90
|
+
return context.current_trace()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def trace_headers() -> Dict[str, str]:
|
|
94
|
+
"""Headers to add to an outbound HTTP call to another instrumented
|
|
95
|
+
service: the active trace with its hop incremented, or {}::
|
|
96
|
+
|
|
97
|
+
requests.post(url, json=body, headers={**headers, **devbench.trace_headers()})
|
|
98
|
+
"""
|
|
99
|
+
trace = context.current_trace()
|
|
100
|
+
if trace is None:
|
|
101
|
+
return {}
|
|
102
|
+
forwarded = trace.next_hop()
|
|
103
|
+
return {TRACE_HEADER: str(forwarded)} if forwarded is not None else {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def capture_logs() -> bool:
|
|
107
|
+
"""Keeps recent log lines per trace for triage (direct mode). The
|
|
108
|
+
integrations call this; a plain WSGI/ASGI app may call it itself."""
|
|
109
|
+
return _core.capture_logs()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def flush(timeout: float = 5.0) -> bool:
|
|
113
|
+
"""Sends whatever has been counted now, waiting at most ``timeout``
|
|
114
|
+
seconds. Returns True when nothing is left unsent."""
|
|
115
|
+
return _core.flush(timeout)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test(out: Any = None) -> bool:
|
|
119
|
+
"""Checks DEVBENCH_DSN against ingest and prints the result; the same as
|
|
120
|
+
``python -m devbench test``."""
|
|
121
|
+
return _core.self_test(out)
|
devbench/__main__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""``python -m devbench test``: checks DEVBENCH_DSN against Dev Bench and
|
|
2
|
+
prints the HTTP result, without waiting for a flush. Exits non-zero when the
|
|
3
|
+
DSN is missing, malformed, unreachable or rejected."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from . import _core
|
|
11
|
+
|
|
12
|
+
USAGE = "usage: python -m devbench test\n"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
16
|
+
args = sys.argv[1:] if argv is None else argv
|
|
17
|
+
if args != ["test"]:
|
|
18
|
+
sys.stderr.write(USAGE)
|
|
19
|
+
return 2
|
|
20
|
+
return 0 if _core.self_test(sys.stdout) else 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if __name__ == "__main__":
|
|
24
|
+
sys.exit(main())
|
devbench/_core.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Configuration and transport selection.
|
|
2
|
+
|
|
3
|
+
One transport per process, decided at the first report:
|
|
4
|
+
|
|
5
|
+
* ``DEVBENCH_ENABLED=false``: none.
|
|
6
|
+
* ``DEVBENCH_DSN`` (or ``ADT_DSN``): direct to Dev Bench. A DSN that does not
|
|
7
|
+
parse logs one warning and reports nothing.
|
|
8
|
+
* neither: none. (The Ruby and Go SDKs fall back to a local sidecar socket;
|
|
9
|
+
this SDK is direct-only, so an environment without a DSN, such as
|
|
10
|
+
development or CI, sends nothing and says nothing.)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import atexit
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
import threading
|
|
19
|
+
from typing import IO, Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
from . import logs
|
|
22
|
+
from .config import DSN, Configuration, InvalidDSN
|
|
23
|
+
from .direct import EXIT_TIMEOUT, DirectTransport
|
|
24
|
+
|
|
25
|
+
_LOGGER = logging.getLogger("devbench")
|
|
26
|
+
_lock = threading.RLock()
|
|
27
|
+
_config: Optional[Configuration] = None
|
|
28
|
+
_transport: Any = None
|
|
29
|
+
_warned: Dict[str, bool] = {}
|
|
30
|
+
_exit_flush_installed = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _NullTransport:
|
|
34
|
+
"""Reporting is off: everything is accepted and dropped."""
|
|
35
|
+
|
|
36
|
+
def deliver(self, payload: Any) -> None:
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
def flush(self, timeout: float = 0) -> bool:
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
def start(self) -> None:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
def stop(self) -> None:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
NULL = _NullTransport()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def config() -> Configuration:
|
|
53
|
+
"""The configuration, read from the environment on first use."""
|
|
54
|
+
global _config
|
|
55
|
+
current = _config
|
|
56
|
+
if current is not None:
|
|
57
|
+
return current
|
|
58
|
+
with _lock:
|
|
59
|
+
if _config is None:
|
|
60
|
+
_config = Configuration()
|
|
61
|
+
return _config
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def transport() -> Any:
|
|
65
|
+
"""The active transport. Built once, on first use, so a DSN configured
|
|
66
|
+
from code before the first report is seen."""
|
|
67
|
+
global _transport
|
|
68
|
+
current = _transport
|
|
69
|
+
if current is not None:
|
|
70
|
+
return current
|
|
71
|
+
with _lock:
|
|
72
|
+
if _transport is None:
|
|
73
|
+
_transport = _build_transport()
|
|
74
|
+
return _transport
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def enabled() -> bool:
|
|
78
|
+
try:
|
|
79
|
+
return config().enabled
|
|
80
|
+
except Exception:
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def direct() -> bool:
|
|
85
|
+
"""True when this process reports to Dev Bench (a DSN is set and
|
|
86
|
+
parses)."""
|
|
87
|
+
try:
|
|
88
|
+
return isinstance(transport(), DirectTransport)
|
|
89
|
+
except Exception:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def configure(dsn: Optional[str] = None, service: Optional[str] = None, release: Optional[str] = None,
|
|
94
|
+
enabled: Optional[bool] = None, flush_interval: Optional[float] = None) -> None:
|
|
95
|
+
"""Configures from code instead of (or on top of) the environment. Only
|
|
96
|
+
the fields given change. Takes effect from the next report. Never raises
|
|
97
|
+
into the caller."""
|
|
98
|
+
try:
|
|
99
|
+
cfg = config()
|
|
100
|
+
if dsn is not None:
|
|
101
|
+
cfg.dsn = dsn
|
|
102
|
+
if service is not None:
|
|
103
|
+
cfg.service = service
|
|
104
|
+
if release is not None:
|
|
105
|
+
cfg.release = release
|
|
106
|
+
if enabled is not None:
|
|
107
|
+
cfg.enabled = bool(enabled)
|
|
108
|
+
if flush_interval is not None:
|
|
109
|
+
cfg.flush_interval = float(flush_interval)
|
|
110
|
+
_reset_transport()
|
|
111
|
+
# Capture already in place follows the new configuration.
|
|
112
|
+
if logs.active() or _capture_requested:
|
|
113
|
+
_apply_capture()
|
|
114
|
+
except Exception as e:
|
|
115
|
+
warn_once("configure", "devbench.configure raised %s; reporting is unchanged" % type(e).__name__)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_capture_requested = False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def capture_logs() -> bool:
|
|
122
|
+
"""Keeps recent log lines, per trace, for triage to ask for (direct mode
|
|
123
|
+
only; elsewhere a no-op): a handler on the root logger. Every
|
|
124
|
+
integration calls this; a plain WSGI/ASGI app may call it itself.
|
|
125
|
+
Returns True when capture is on. Never raises."""
|
|
126
|
+
global _capture_requested
|
|
127
|
+
try:
|
|
128
|
+
_capture_requested = True
|
|
129
|
+
return _apply_capture()
|
|
130
|
+
except Exception:
|
|
131
|
+
return False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _apply_capture() -> bool:
|
|
135
|
+
if enabled() and direct():
|
|
136
|
+
logs.install()
|
|
137
|
+
logs.activate()
|
|
138
|
+
return True
|
|
139
|
+
logs.deactivate()
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def flush(timeout: float = 5.0) -> bool:
|
|
144
|
+
"""Sends whatever has been counted, now, waiting at most ``timeout``
|
|
145
|
+
seconds. Returns True when nothing is left unsent. Never raises."""
|
|
146
|
+
try:
|
|
147
|
+
return bool(transport().flush(timeout=timeout))
|
|
148
|
+
except Exception:
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def self_test(out: Optional[IO[str]] = None) -> bool:
|
|
153
|
+
"""Checks the DSN against ingest and prints the result
|
|
154
|
+
(``python -m devbench test``). Returns True when ingest accepted the key.
|
|
155
|
+
Never raises."""
|
|
156
|
+
out = out if out is not None else sys.stdout
|
|
157
|
+
try:
|
|
158
|
+
cfg = config()
|
|
159
|
+
if not cfg.enabled:
|
|
160
|
+
out.write("Dev Bench is disabled (DEVBENCH_ENABLED=false); nothing was sent.\n")
|
|
161
|
+
return False
|
|
162
|
+
if not (cfg.dsn or "").strip():
|
|
163
|
+
out.write("DEVBENCH_DSN is not set. Set it to the DSN Dev Bench gave you for this environment "
|
|
164
|
+
"(https://<public>:<secret>@<host>, from `adt dsn create`), or call devbench.configure(dsn=...).\n")
|
|
165
|
+
return False
|
|
166
|
+
try:
|
|
167
|
+
dsn = DSN.parse(cfg.dsn)
|
|
168
|
+
except InvalidDSN as e:
|
|
169
|
+
out.write("DEVBENCH_DSN %s.\n" % e)
|
|
170
|
+
return False
|
|
171
|
+
client = DirectTransport(dsn, cfg.resolved_service(), cfg.resolved_release())
|
|
172
|
+
return client.self_test(out)
|
|
173
|
+
except Exception as e:
|
|
174
|
+
out.write("Dev Bench test failed: %s: %s\n" % (type(e).__name__, e))
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def reset() -> None:
|
|
179
|
+
"""Drops the active transport (stopping a direct client's thread) and the
|
|
180
|
+
configuration, so both are rebuilt from scratch. For tests."""
|
|
181
|
+
global _config, _capture_requested
|
|
182
|
+
_reset_transport()
|
|
183
|
+
logs.deactivate()
|
|
184
|
+
with _lock:
|
|
185
|
+
_config = None
|
|
186
|
+
_capture_requested = False
|
|
187
|
+
_warned.clear()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def warn_once(kind: str, message: str) -> None:
|
|
191
|
+
"""Logs a problem with Dev Bench itself, once per kind per process, on
|
|
192
|
+
the ``devbench`` logger. Never raises."""
|
|
193
|
+
try:
|
|
194
|
+
with _lock:
|
|
195
|
+
if _warned.get(kind):
|
|
196
|
+
return
|
|
197
|
+
_warned[kind] = True
|
|
198
|
+
_LOGGER.warning("[devbench] %s", message)
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def rebuild_transport() -> None:
|
|
204
|
+
"""Drops a transport built before the integrations said what the app is
|
|
205
|
+
(its counts are flushed first), so the next use builds it with the right
|
|
206
|
+
service and root. Nothing to do when none was built."""
|
|
207
|
+
if _transport is not None:
|
|
208
|
+
_reset_transport()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _reset_transport() -> None:
|
|
212
|
+
global _transport
|
|
213
|
+
with _lock:
|
|
214
|
+
old, _transport = _transport, None
|
|
215
|
+
stop = getattr(old, "stop", None)
|
|
216
|
+
if stop is not None:
|
|
217
|
+
stop()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _build_transport() -> Any:
|
|
221
|
+
try:
|
|
222
|
+
cfg = config()
|
|
223
|
+
if not cfg.enabled or not (cfg.dsn or "").strip():
|
|
224
|
+
return NULL
|
|
225
|
+
try:
|
|
226
|
+
dsn = DSN.parse(cfg.dsn)
|
|
227
|
+
except InvalidDSN as e:
|
|
228
|
+
warn_once("dsn", "DEVBENCH_DSN %s; nothing will be reported" % e)
|
|
229
|
+
return NULL
|
|
230
|
+
_install_exit_flush()
|
|
231
|
+
return DirectTransport(dsn, cfg.resolved_service(), cfg.resolved_release(),
|
|
232
|
+
interval=cfg.flush_interval, warn=warn_once)
|
|
233
|
+
except Exception:
|
|
234
|
+
return NULL
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _install_exit_flush() -> None:
|
|
238
|
+
"""One last flush when the process exits, bounded to 2 s (spec)."""
|
|
239
|
+
global _exit_flush_installed
|
|
240
|
+
if _exit_flush_installed:
|
|
241
|
+
return
|
|
242
|
+
_exit_flush_installed = True
|
|
243
|
+
atexit.register(_exit_flush)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _exit_flush() -> None:
|
|
247
|
+
current = _transport
|
|
248
|
+
if isinstance(current, DirectTransport):
|
|
249
|
+
try:
|
|
250
|
+
current.flush(timeout=EXIT_TIMEOUT)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
devbench/_integration.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""What every framework integration shares."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from . import _core, context, reporter
|
|
10
|
+
from .trace import HEADER as TRACE_HEADER
|
|
11
|
+
|
|
12
|
+
HANDLED_HEADER = "x-adt-handled"
|
|
13
|
+
EXPOSE_HEADER = "Access-Control-Expose-Headers"
|
|
14
|
+
|
|
15
|
+
# Names a framework gives an app that say nothing about which app it is.
|
|
16
|
+
_GENERIC = frozenset(["", "__main__", "app", "main", "wsgi", "asgi", "application", "server", "api"])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup(service: Optional[str] = None, root: Optional[str] = None) -> None:
|
|
20
|
+
"""Records what the framework knows about the app (its name, its root,
|
|
21
|
+
whether this is a Celery worker), unless something more specific already
|
|
22
|
+
did, then starts log capture. A transport built before the hints arrived
|
|
23
|
+
is rebuilt, so the service name never depends on import order. Never
|
|
24
|
+
raises."""
|
|
25
|
+
try:
|
|
26
|
+
cfg = _core.config()
|
|
27
|
+
changed = False
|
|
28
|
+
name = (service or "").split(".")[0].strip()
|
|
29
|
+
if name not in _GENERIC and not cfg.framework_service:
|
|
30
|
+
cfg.framework_service = name
|
|
31
|
+
changed = True
|
|
32
|
+
if root and not cfg.app_root:
|
|
33
|
+
cfg.app_root = str(root)
|
|
34
|
+
changed = True
|
|
35
|
+
if not cfg.worker and celery_worker_command():
|
|
36
|
+
cfg.worker = "celery"
|
|
37
|
+
changed = True
|
|
38
|
+
if changed:
|
|
39
|
+
_core.rebuild_transport()
|
|
40
|
+
_core.capture_logs()
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def celery_worker_command(argv: Optional[List[str]] = None) -> bool:
|
|
46
|
+
"""`celery -A proj worker ...`: the command line is all there is before
|
|
47
|
+
Celery's worker_init fires, and the service must be known before the
|
|
48
|
+
first report."""
|
|
49
|
+
argv = sys.argv if argv is None else argv
|
|
50
|
+
if not argv:
|
|
51
|
+
return False
|
|
52
|
+
words = [a for a in argv[1:] if not a.startswith("-")]
|
|
53
|
+
return "worker" in words and "celery" in os.path.basename(argv[0] or "").lower()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def callable_path(fn: Any) -> Optional[str]:
|
|
57
|
+
"""``module.qualname`` of a view, endpoint or task function: where a
|
|
58
|
+
failure surfaced (the report's ``symbol``)."""
|
|
59
|
+
try:
|
|
60
|
+
target = getattr(fn, "view_class", None) or getattr(fn, "cls", None) or fn
|
|
61
|
+
module = getattr(target, "__module__", None)
|
|
62
|
+
qual = getattr(target, "__qualname__", None) or getattr(target, "__name__", None)
|
|
63
|
+
if not qual:
|
|
64
|
+
return None
|
|
65
|
+
return "%s.%s" % (module, qual) if module else str(qual)
|
|
66
|
+
except Exception:
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def report_request_error(error: Any, symbol: Optional[str]) -> None:
|
|
71
|
+
reporter.capture(error, "request", False, symbol)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def handled_count() -> int:
|
|
75
|
+
"""Failures handled during this traced request (0 when it carries no
|
|
76
|
+
trace: the browser that reads the header always sends one)."""
|
|
77
|
+
scope = context.current()
|
|
78
|
+
if scope is None or scope.trace is None:
|
|
79
|
+
return 0
|
|
80
|
+
return scope.handled
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def exposed(current: Optional[str]) -> str:
|
|
84
|
+
"""Access-Control-Expose-Headers with x-adt-handled added. A browser
|
|
85
|
+
cannot read a custom response header cross-origin unless it is listed
|
|
86
|
+
there (an SPA on another origin than its API would see nothing)."""
|
|
87
|
+
current = current or ""
|
|
88
|
+
if HANDLED_HEADER in current.lower():
|
|
89
|
+
return current
|
|
90
|
+
return "%s, %s" % (current, HANDLED_HEADER) if current else HANDLED_HEADER
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def with_handled(headers: List[Tuple[str, str]]) -> List[Tuple[str, str]]:
|
|
94
|
+
"""A WSGI/ASGI header list with x-adt-handled set when failures were
|
|
95
|
+
handled during this traced request; the list unchanged otherwise. The
|
|
96
|
+
value is a count and nothing else: any script on the page can read it."""
|
|
97
|
+
count = handled_count()
|
|
98
|
+
if count <= 0:
|
|
99
|
+
return headers
|
|
100
|
+
out = [(k, v) for k, v in headers if k.lower() not in (HANDLED_HEADER, EXPOSE_HEADER.lower())]
|
|
101
|
+
current = next((v for k, v in headers if k.lower() == EXPOSE_HEADER.lower()), None)
|
|
102
|
+
out.append((HANDLED_HEADER, str(count)))
|
|
103
|
+
out.append((EXPOSE_HEADER, exposed(current)))
|
|
104
|
+
return out
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["TRACE_HEADER", "HANDLED_HEADER", "EXPOSE_HEADER", "setup", "callable_path", "report_request_error", "handled_count", "exposed", "with_handled"]
|
devbench/asgi.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""ASGI middleware for FastAPI, Starlette and any ASGI app::
|
|
2
|
+
|
|
3
|
+
from devbench.asgi import DevbenchMiddleware # or devbench.fastapi
|
|
4
|
+
app.add_middleware(DevbenchMiddleware)
|
|
5
|
+
|
|
6
|
+
Accepts the correlation id for the duration of the request, scopes identity
|
|
7
|
+
to it, reports an exception that escapes the app and re-raises the same
|
|
8
|
+
object (Starlette's ServerErrorMiddleware, outside it, still answers 500 and
|
|
9
|
+
runs your handlers), and marks a traced response during which failures were
|
|
10
|
+
handled (``x-adt-handled``).
|
|
11
|
+
|
|
12
|
+
A plain ASGI middleware, not BaseHTTPMiddleware: the request's context (the
|
|
13
|
+
trace, the user) reaches the endpoint, its thread pool and its logging calls
|
|
14
|
+
unchanged, and responses stream as before.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Awaitable, Callable, MutableMapping, Optional
|
|
20
|
+
|
|
21
|
+
from . import _core, _integration, context
|
|
22
|
+
from .trace import Trace
|
|
23
|
+
|
|
24
|
+
Scope = MutableMapping[str, Any]
|
|
25
|
+
Message = MutableMapping[str, Any]
|
|
26
|
+
Receive = Callable[[], Awaitable[Message]]
|
|
27
|
+
Send = Callable[[Message], Awaitable[None]]
|
|
28
|
+
|
|
29
|
+
_TRACE_HEADER = b"x-adt-trace"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DevbenchMiddleware:
|
|
33
|
+
def __init__(self, app: Callable[[Scope, Receive, Send], Awaitable[None]]) -> None:
|
|
34
|
+
self.app = app
|
|
35
|
+
_integration.setup()
|
|
36
|
+
|
|
37
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
38
|
+
if scope.get("type") != "http" or not _core.enabled():
|
|
39
|
+
await self.app(scope, receive, send)
|
|
40
|
+
return
|
|
41
|
+
trace = _parse(scope)
|
|
42
|
+
|
|
43
|
+
async def send_wrapper(message: Message) -> None:
|
|
44
|
+
if message.get("type") == "http.response.start":
|
|
45
|
+
try:
|
|
46
|
+
message = _with_handled(message)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
await send(message)
|
|
50
|
+
|
|
51
|
+
with context.scoped(trace):
|
|
52
|
+
try:
|
|
53
|
+
await self.app(scope, receive, send_wrapper)
|
|
54
|
+
except Exception as error:
|
|
55
|
+
_integration.report_request_error(error, _symbol(scope))
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _parse(scope: Scope) -> Optional[Trace]:
|
|
60
|
+
try:
|
|
61
|
+
for name, value in scope.get("headers") or ():
|
|
62
|
+
if name.lower() == _TRACE_HEADER:
|
|
63
|
+
return Trace.parse(value)
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _symbol(scope: Scope) -> Optional[str]:
|
|
70
|
+
# Starlette's router puts the matched endpoint in the scope.
|
|
71
|
+
endpoint = scope.get("endpoint")
|
|
72
|
+
return _integration.callable_path(endpoint) if endpoint is not None else None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _with_handled(message: Message) -> Message:
|
|
76
|
+
if _integration.handled_count() <= 0:
|
|
77
|
+
return message
|
|
78
|
+
headers = [(k.decode("latin-1"), v.decode("latin-1")) for k, v in message.get("headers") or ()]
|
|
79
|
+
updated = _integration.with_handled(headers)
|
|
80
|
+
message = dict(message)
|
|
81
|
+
message["headers"] = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in updated]
|
|
82
|
+
return message
|