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 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
@@ -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