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.
- debugbundle/__init__.py +165 -0
- debugbundle/config.py +192 -0
- debugbundle/core.py +811 -0
- debugbundle/integrations/__init__.py +16 -0
- debugbundle/integrations/common.py +77 -0
- debugbundle/integrations/django.py +53 -0
- debugbundle/integrations/fastapi.py +94 -0
- debugbundle/integrations/flask.py +67 -0
- debugbundle/integrations/relay_django.py +50 -0
- debugbundle/integrations/relay_fastapi.py +46 -0
- debugbundle/integrations/relay_flask.py +46 -0
- debugbundle/logger_integrations.py +154 -0
- debugbundle/py.typed +0 -0
- debugbundle/redaction.py +29 -0
- debugbundle/relay.py +261 -0
- debugbundle/suppression.py +124 -0
- debugbundle/transport.py +59 -0
- debugbundle/trigger_token.py +143 -0
- debugbundle_python-0.1.0.dist-info/METADATA +66 -0
- debugbundle_python-0.1.0.dist-info/RECORD +23 -0
- debugbundle_python-0.1.0.dist-info/WHEEL +5 -0
- debugbundle_python-0.1.0.dist-info/licenses/LICENSE +17 -0
- debugbundle_python-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
debugbundle/redaction.py
ADDED
|
@@ -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
|