debugbundle-python 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.
@@ -0,0 +1,16 @@
1
+ from .django import DebugBundleDjangoMiddleware
2
+ from .fastapi import DebugBundleFastAPIMiddleware, instrument_fastapi
3
+ from .flask import instrument_flask
4
+ from .relay_django import create_django_relay_view
5
+ from .relay_fastapi import create_fastapi_relay_handler
6
+ from .relay_flask import create_flask_relay_handler
7
+
8
+ __all__ = [
9
+ "DebugBundleDjangoMiddleware",
10
+ "DebugBundleFastAPIMiddleware",
11
+ "create_django_relay_view",
12
+ "create_fastapi_relay_handler",
13
+ "create_flask_relay_handler",
14
+ "instrument_fastapi",
15
+ "instrument_flask",
16
+ ]
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from collections.abc import Mapping
5
+ from typing import Any
6
+
7
+ from ..core import DebugBundleSdk
8
+
9
+ TRACE_ID_HEADER = "x-debugbundle-trace-id"
10
+ REQUEST_ID_HEADERS = ("x-request-id", "x-correlation-id")
11
+
12
+
13
+ def now_seconds() -> float:
14
+ return time.time()
15
+
16
+
17
+ def duration_ms(started_at: float, finished_at: float | None = None) -> int:
18
+ end = now_seconds() if finished_at is None else finished_at
19
+ return max(0, int((end - started_at) * 1000))
20
+
21
+
22
+ def normalize_headers(headers: Mapping[str, Any]) -> dict[str, str]:
23
+ normalized: dict[str, str] = {}
24
+ for key, value in headers.items():
25
+ normalized[str(key).lower()] = str(value)
26
+ return normalized
27
+
28
+
29
+ def correlation_context(headers: Mapping[str, Any]) -> dict[str, str]:
30
+ normalized = normalize_headers(headers)
31
+ context: dict[str, str] = {}
32
+ trace_id = normalized.get(TRACE_ID_HEADER)
33
+ if trace_id:
34
+ context["trace_id"] = trace_id
35
+ for header_name in REQUEST_ID_HEADERS:
36
+ request_id = normalized.get(header_name)
37
+ if request_id:
38
+ context["request_id"] = request_id
39
+ break
40
+ return context
41
+
42
+
43
+ def normalize_query_items(items: Mapping[str, Any] | None = None) -> dict[str, str]:
44
+ if items is None:
45
+ return {}
46
+ return {str(key): str(value) for key, value in items.items()}
47
+
48
+
49
+ def request_payload(
50
+ *,
51
+ method: str,
52
+ path: str,
53
+ headers: Mapping[str, Any],
54
+ query: Mapping[str, Any] | None = None,
55
+ ) -> dict[str, object]:
56
+ return {
57
+ "method": method,
58
+ "path": path,
59
+ "headers": normalize_headers(headers),
60
+ "query": normalize_query_items(query),
61
+ }
62
+
63
+
64
+ def response_payload(*, status_code: int, started_at: float) -> dict[str, object]:
65
+ return {
66
+ "status_code": status_code,
67
+ "duration_ms": duration_ms(started_at),
68
+ }
69
+
70
+
71
+ def resolve_sdk(sdk: DebugBundleSdk | None) -> DebugBundleSdk:
72
+ if sdk is not None:
73
+ return sdk
74
+
75
+ from .. import _sdk
76
+
77
+ return _sdk
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .common import correlation_context, request_payload, resolve_sdk, response_payload
6
+
7
+
8
+ class DebugBundleDjangoMiddleware:
9
+ def __init__(self, get_response: Any, sdk: Any = None) -> None:
10
+ self._get_response = get_response
11
+ self._sdk = resolve_sdk(sdk)
12
+ self._sdk.capture_logging()
13
+
14
+ def __call__(self, request: Any) -> Any:
15
+ started_at = self._sdk._time_provider()
16
+ context_token = self._sdk._bind_scoped_context(correlation_context(request.headers))
17
+ trigger_token = self._sdk.begin_request(
18
+ request_payload(
19
+ method=request.method,
20
+ path=request.path,
21
+ headers=request.headers,
22
+ query=request.GET,
23
+ )
24
+ )
25
+ try:
26
+ response = self._get_response(request)
27
+ except Exception as error:
28
+ self._sdk.capture_exception(
29
+ error,
30
+ context={
31
+ "request": request_payload(
32
+ method=request.method,
33
+ path=request.path,
34
+ headers=request.headers,
35
+ query=request.GET,
36
+ )
37
+ },
38
+ )
39
+ raise
40
+ else:
41
+ self._sdk.capture_request(
42
+ request_payload(
43
+ method=request.method,
44
+ path=request.path,
45
+ headers=request.headers,
46
+ query=request.GET,
47
+ ),
48
+ response_payload(status_code=response.status_code, started_at=started_at),
49
+ )
50
+ return response
51
+ finally:
52
+ self._sdk.end_request(trigger_token)
53
+ self._sdk._reset_scoped_context(context_token)
@@ -0,0 +1,94 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .common import correlation_context, request_payload, resolve_sdk, response_payload
6
+
7
+
8
+ class DebugBundleFastAPIMiddleware:
9
+ def __init__(self, app: Any, sdk: Any) -> None:
10
+ self.app = app
11
+ self.sdk = resolve_sdk(sdk)
12
+
13
+ async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
14
+ if scope.get("type") != "http":
15
+ await self.app(scope, receive, send)
16
+ return
17
+
18
+ headers = {
19
+ key.decode("latin-1"): value.decode("latin-1")
20
+ for key, value in scope.get("headers", [])
21
+ }
22
+ query_string = scope.get("query_string", b"").decode("latin-1")
23
+ query: dict[str, str] = {}
24
+ if query_string:
25
+ for pair in query_string.split("&"):
26
+ if not pair:
27
+ continue
28
+ key, _, value = pair.partition("=")
29
+ query[key] = value
30
+
31
+ started_at = self.sdk._time_provider()
32
+ status_holder: dict[str, int] = {}
33
+ context_token = self.sdk._bind_scoped_context(correlation_context(headers))
34
+ trigger_token = self.sdk.begin_request(
35
+ request_payload(
36
+ method=scope.get("method", "GET"),
37
+ path=scope.get("path", "/"),
38
+ headers=headers,
39
+ query=query,
40
+ )
41
+ )
42
+
43
+ async def wrapped_send(message: dict[str, Any]) -> None:
44
+ if message.get("type") == "http.response.start":
45
+ status_holder["status_code"] = int(message.get("status", 200))
46
+ await send(message)
47
+
48
+ try:
49
+ await self.app(scope, receive, wrapped_send)
50
+ except Exception as error:
51
+ self.sdk.capture_request(
52
+ request_payload(
53
+ method=scope.get("method", "GET"),
54
+ path=scope.get("path", "/"),
55
+ headers=headers,
56
+ query=query,
57
+ ),
58
+ response_payload(status_code=500, started_at=started_at),
59
+ )
60
+ self.sdk.capture_exception(
61
+ error,
62
+ context={
63
+ "request": request_payload(
64
+ method=scope.get("method", "GET"),
65
+ path=scope.get("path", "/"),
66
+ headers=headers,
67
+ query=query,
68
+ )
69
+ },
70
+ )
71
+ raise
72
+ finally:
73
+ status_code = status_holder.get("status_code")
74
+ try:
75
+ if status_code is not None:
76
+ self.sdk.capture_request(
77
+ request_payload(
78
+ method=scope.get("method", "GET"),
79
+ path=scope.get("path", "/"),
80
+ headers=headers,
81
+ query=query,
82
+ ),
83
+ response_payload(status_code=status_code, started_at=started_at),
84
+ )
85
+ finally:
86
+ self.sdk.end_request(trigger_token)
87
+ self.sdk._reset_scoped_context(context_token)
88
+
89
+
90
+ def instrument_fastapi(app: Any, sdk: Any = None) -> Any:
91
+ resolved_sdk = resolve_sdk(sdk)
92
+ resolved_sdk.capture_logging()
93
+ app.add_middleware(DebugBundleFastAPIMiddleware, sdk=resolved_sdk)
94
+ return app
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .common import correlation_context, request_payload, resolve_sdk, response_payload
6
+
7
+
8
+ def instrument_flask(app: Any, sdk: Any = None) -> Any:
9
+ resolved_sdk = resolve_sdk(sdk)
10
+ resolved_sdk.capture_logging(app.logger)
11
+
12
+ from flask import g, request
13
+
14
+ @app.before_request
15
+ def debugbundle_before_request() -> None:
16
+ g._debugbundle_started_at = resolved_sdk._time_provider()
17
+ g._debugbundle_context_token = resolved_sdk._bind_scoped_context(
18
+ correlation_context(dict(request.headers.items()))
19
+ )
20
+ g._debugbundle_trigger_token = resolved_sdk.begin_request(
21
+ request_payload(
22
+ method=request.method,
23
+ path=request.path,
24
+ headers=dict(request.headers.items()),
25
+ query=dict(request.args.items()),
26
+ )
27
+ )
28
+
29
+ @app.after_request
30
+ def debugbundle_after_request(response: Any) -> Any:
31
+ started_at = getattr(g, "_debugbundle_started_at", resolved_sdk._time_provider())
32
+ resolved_sdk.capture_request(
33
+ request_payload(
34
+ method=request.method,
35
+ path=request.path,
36
+ headers=dict(request.headers.items()),
37
+ query=dict(request.args.items()),
38
+ ),
39
+ response_payload(status_code=response.status_code, started_at=started_at),
40
+ )
41
+ return response
42
+
43
+ @app.teardown_request
44
+ def debugbundle_teardown_request(error: BaseException | None) -> None:
45
+ token = getattr(g, "_debugbundle_context_token", None)
46
+ trigger_token = getattr(g, "_debugbundle_trigger_token", None)
47
+ try:
48
+ if error is None:
49
+ return
50
+ resolved_sdk.capture_exception(
51
+ error,
52
+ context={
53
+ "request": request_payload(
54
+ method=request.method,
55
+ path=request.path,
56
+ headers=dict(request.headers.items()),
57
+ query=dict(request.args.items()),
58
+ ),
59
+ },
60
+ )
61
+ finally:
62
+ if trigger_token is not None:
63
+ resolved_sdk.end_request(trigger_token)
64
+ if token is not None:
65
+ resolved_sdk._reset_scoped_context(token)
66
+
67
+ return app
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..relay import BrowserRelayHandler
6
+
7
+
8
+ def create_django_relay_view(
9
+ *,
10
+ allowed_origins: list[str] | None = None,
11
+ max_body_bytes: int = 262_144,
12
+ rate_limit_per_minute: int = 60,
13
+ on_accept: Any = None,
14
+ ) -> Any:
15
+ handler = BrowserRelayHandler(
16
+ allowed_origins=allowed_origins or [],
17
+ max_body_bytes=max_body_bytes,
18
+ rate_limit_per_minute=rate_limit_per_minute,
19
+ on_accept=on_accept,
20
+ )
21
+
22
+ def view(request: Any) -> Any:
23
+ from django.http import JsonResponse # type: ignore[import-untyped]
24
+
25
+ headers: dict[str, str] = {}
26
+ if hasattr(request, "headers"):
27
+ headers = {str(key).lower(): str(value) for key, value in request.headers.items()}
28
+
29
+ response = handler.handle(
30
+ {
31
+ "method": request.method,
32
+ "headers": headers,
33
+ "body": request.body.decode("utf-8") if isinstance(request.body, bytes) else str(request.body),
34
+ "ipAddress": _get_client_ip(request),
35
+ }
36
+ )
37
+
38
+ if response.body is not None:
39
+ return JsonResponse(response.body, status=response.status, safe=False)
40
+
41
+ return JsonResponse({}, status=response.status)
42
+
43
+ return view
44
+
45
+
46
+ def _get_client_ip(request: Any) -> str | None:
47
+ forwarded = request.META.get("HTTP_X_FORWARDED_FOR")
48
+ if forwarded:
49
+ return str(forwarded).split(",")[0].strip()
50
+ return request.META.get("REMOTE_ADDR")
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from starlette.requests import Request
6
+ from starlette.responses import JSONResponse, Response
7
+
8
+ from ..relay import BrowserRelayHandler
9
+
10
+
11
+ def create_fastapi_relay_handler(
12
+ *,
13
+ allowed_origins: list[str] | None = None,
14
+ max_body_bytes: int = 262_144,
15
+ rate_limit_per_minute: int = 60,
16
+ on_accept: Any = None,
17
+ route_path: str = "/debugbundle/browser",
18
+ ) -> Any:
19
+ handler = BrowserRelayHandler(
20
+ allowed_origins=allowed_origins or [],
21
+ max_body_bytes=max_body_bytes,
22
+ rate_limit_per_minute=rate_limit_per_minute,
23
+ on_accept=on_accept,
24
+ )
25
+
26
+ def register(app: Any) -> None:
27
+ @app.post(route_path)
28
+ async def debugbundle_browser_relay(request: Request) -> Response:
29
+ body = await request.body()
30
+ headers = {str(key): str(value) for key, value in request.headers.items()}
31
+ ip_address = request.client.host if request.client else None
32
+
33
+ response = handler.handle(
34
+ {
35
+ "method": request.method,
36
+ "headers": headers,
37
+ "body": body.decode("utf-8") if isinstance(body, bytes) else str(body),
38
+ "ipAddress": ip_address,
39
+ }
40
+ )
41
+
42
+ if response.body is not None:
43
+ return JSONResponse(content=response.body, status_code=response.status)
44
+ return Response(status_code=response.status)
45
+
46
+ return register
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from ..relay import BrowserRelayHandler
6
+
7
+
8
+ def create_flask_relay_handler(
9
+ *,
10
+ allowed_origins: list[str] | None = None,
11
+ max_body_bytes: int = 262_144,
12
+ rate_limit_per_minute: int = 60,
13
+ on_accept: Any = None,
14
+ route_path: str = "/debugbundle/browser",
15
+ ) -> Any:
16
+ handler = BrowserRelayHandler(
17
+ allowed_origins=allowed_origins or [],
18
+ max_body_bytes=max_body_bytes,
19
+ rate_limit_per_minute=rate_limit_per_minute,
20
+ on_accept=on_accept,
21
+ )
22
+
23
+ def register(app: Any) -> None:
24
+ from flask import Response, request
25
+
26
+ @app.route(route_path, methods=["POST"])
27
+ def debugbundle_browser_relay() -> Response:
28
+ response = handler.handle(
29
+ {
30
+ "method": request.method,
31
+ "headers": dict(request.headers.items()),
32
+ "body": request.get_data(as_text=True),
33
+ "ipAddress": request.remote_addr,
34
+ }
35
+ )
36
+
37
+ import json
38
+
39
+ body = json.dumps(response.body) if response.body is not None else ""
40
+ return Response(
41
+ body,
42
+ status=response.status,
43
+ content_type="application/json",
44
+ )
45
+
46
+ return register
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from typing import Any, Protocol
5
+
6
+
7
+ class LogCaptureApi(Protocol):
8
+ def capture_log(
9
+ self,
10
+ message: str,
11
+ level: str = "warning",
12
+ context: dict[str, object] | None = None,
13
+ ) -> None: ...
14
+
15
+
16
+ def attach_optional_integrations(
17
+ sdk: LogCaptureApi,
18
+ on_diagnostic: Callable[[dict[str, object]], None] | None = None,
19
+ ) -> list[Callable[[], None]]:
20
+ restorers: list[Callable[[], None]] = []
21
+
22
+ structlog_restore = _attach_structlog(sdk, on_diagnostic=on_diagnostic)
23
+ if structlog_restore is not None:
24
+ restorers.append(structlog_restore)
25
+
26
+ loguru_restore = _attach_loguru(sdk, on_diagnostic=on_diagnostic)
27
+ if loguru_restore is not None:
28
+ restorers.append(loguru_restore)
29
+
30
+ return restorers
31
+
32
+
33
+ def _attach_structlog(
34
+ sdk: LogCaptureApi,
35
+ on_diagnostic: Callable[[dict[str, object]], None] | None = None,
36
+ ) -> Callable[[], None] | None:
37
+ try:
38
+ import structlog
39
+ except Exception:
40
+ return None
41
+
42
+ try:
43
+ original_get_logger = structlog.get_logger
44
+ if getattr(original_get_logger, "__debugbundle_structlog_wrapper__", False):
45
+ return None
46
+
47
+ def get_logger(*args: Any, **kwargs: Any) -> Any:
48
+ return _StructlogLoggerProxy(original_get_logger(*args, **kwargs), sdk)
49
+
50
+ setattr(get_logger, "__debugbundle_structlog_wrapper__", True)
51
+ structlog.get_logger = get_logger
52
+
53
+ def restore() -> None:
54
+ structlog.get_logger = original_get_logger
55
+
56
+ return restore
57
+ except Exception as error:
58
+ _emit_diagnostic(on_diagnostic, error)
59
+ return None
60
+
61
+
62
+ def _attach_loguru(
63
+ sdk: LogCaptureApi,
64
+ on_diagnostic: Callable[[dict[str, object]], None] | None = None,
65
+ ) -> Callable[[], None] | None:
66
+ try:
67
+ from loguru import logger as loguru_logger
68
+ except Exception:
69
+ return None
70
+
71
+ try:
72
+ def sink(message: Any) -> None:
73
+ record = getattr(message, "record", None)
74
+ if not isinstance(record, dict):
75
+ return
76
+ context = dict(record.get("extra") or {})
77
+ sdk.capture_log(
78
+ str(record.get("message") or ""),
79
+ level=_normalize_level(str(getattr(record.get("level"), "name", "warning"))),
80
+ context=context or None,
81
+ )
82
+
83
+ sink_id = loguru_logger.add(sink, catch=True)
84
+
85
+ def restore() -> None:
86
+ loguru_logger.remove(sink_id)
87
+
88
+ return restore
89
+ except Exception as error:
90
+ _emit_diagnostic(on_diagnostic, error)
91
+ return None
92
+
93
+
94
+ def _normalize_level(level: str) -> str:
95
+ normalized = level.lower().strip()
96
+ if normalized == "warn":
97
+ return "warning"
98
+ if normalized == "exception":
99
+ return "error"
100
+ return normalized
101
+
102
+
103
+ class _StructlogLoggerProxy:
104
+ def __init__(self, logger: Any, sdk: LogCaptureApi) -> None:
105
+ self._logger = logger
106
+ self._sdk = sdk
107
+
108
+ def bind(self, *args: Any, **kwargs: Any) -> _StructlogLoggerProxy:
109
+ return _StructlogLoggerProxy(self._logger.bind(*args, **kwargs), self._sdk)
110
+
111
+ def new(self, *args: Any, **kwargs: Any) -> _StructlogLoggerProxy:
112
+ return _StructlogLoggerProxy(self._logger.new(*args, **kwargs), self._sdk)
113
+
114
+ def __getattr__(self, name: str) -> Any:
115
+ attribute = getattr(self._logger, name)
116
+ if name not in {
117
+ "debug",
118
+ "info",
119
+ "warning",
120
+ "warn",
121
+ "error",
122
+ "critical",
123
+ "exception",
124
+ } or not callable(attribute):
125
+ return attribute
126
+
127
+ def wrapped(event: Any = None, *args: Any, **kwargs: Any) -> Any:
128
+ context = dict(kwargs)
129
+ for index, value in enumerate(args):
130
+ context[f"arg_{index}"] = value
131
+ self._sdk.capture_log(str(event or ""), level=_normalize_level(name), context=context or None)
132
+ return attribute(event, *args, **kwargs)
133
+
134
+ return wrapped
135
+
136
+
137
+ def _emit_diagnostic(
138
+ on_diagnostic: Callable[[dict[str, object]], None] | None,
139
+ error: Exception,
140
+ ) -> None:
141
+ if on_diagnostic is None:
142
+ return
143
+ on_diagnostic(
144
+ {
145
+ "code": "logger_attach_failed",
146
+ "message": "sdk-python failed to attach a logger integration",
147
+ "metadata": {
148
+ "error": {
149
+ "name": type(error).__name__,
150
+ "message": str(error),
151
+ }
152
+ },
153
+ }
154
+ )
debugbundle/py.typed ADDED
File without changes
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+ DEFAULT_REDACT_FIELDS = {
7
+ "authorization",
8
+ "cookie",
9
+ "credit_card",
10
+ "password",
11
+ "secret",
12
+ "ssn",
13
+ "token",
14
+ }
15
+
16
+ REDACTED_VALUE = "[REDACTED]"
17
+
18
+
19
+ def redact_value(value: Any, redact_fields: set[str]) -> Any:
20
+ if isinstance(value, Mapping):
21
+ return {
22
+ key: REDACTED_VALUE if key.lower() in redact_fields else redact_value(nested_value, redact_fields)
23
+ for key, nested_value in value.items()
24
+ }
25
+
26
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
27
+ return [redact_value(item, redact_fields) for item in value]
28
+
29
+ return value