metrixwire 0.2.1__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.
- metrixwire/__init__.py +64 -0
- metrixwire/client.py +173 -0
- metrixwire/config.py +85 -0
- metrixwire/context.py +36 -0
- metrixwire/patches/__init__.py +52 -0
- metrixwire/patches/db.py +111 -0
- metrixwire/patches/django_patch.py +164 -0
- metrixwire/patches/flask_patch.py +105 -0
- metrixwire/patches/httpclient_patch.py +74 -0
- metrixwire/patches/mysqldb_patch.py +46 -0
- metrixwire/patches/psycopg2_patch.py +64 -0
- metrixwire/patches/psycopg3_patch.py +61 -0
- metrixwire/patches/pymysql_patch.py +46 -0
- metrixwire/patches/redis_patch.py +57 -0
- metrixwire/patches/requests_patch.py +60 -0
- metrixwire/patches/sqlite3_patch.py +62 -0
- metrixwire/patches/starlette_patch.py +88 -0
- metrixwire/patches/web.py +35 -0
- metrixwire/trace.py +122 -0
- metrixwire/transport.py +127 -0
- metrixwire/util.py +95 -0
- metrixwire/wsgi.py +74 -0
- metrixwire-0.2.1.dist-info/METADATA +135 -0
- metrixwire-0.2.1.dist-info/RECORD +25 -0
- metrixwire-0.2.1.dist-info/WHEEL +4 -0
metrixwire/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""MetrixWire — zero-config APM SDK for Python.
|
|
2
|
+
|
|
3
|
+
Call :func:`init` once, as early as possible in your process. Every HTTP request
|
|
4
|
+
becomes a **trace**, and every database query, outbound HTTP call and cache op
|
|
5
|
+
within it becomes a **span** — automatically, with no manual span API and no
|
|
6
|
+
middleware to wire up (a generic WSGI middleware is available as an escape hatch
|
|
7
|
+
for bare apps: ``metrixwire.wsgi.MetrixWireMiddleware``).
|
|
8
|
+
|
|
9
|
+
import metrixwire
|
|
10
|
+
metrixwire.init(api_key="mw_...")
|
|
11
|
+
|
|
12
|
+
Non-blocking: traces are batched and sent off the request path on a background
|
|
13
|
+
daemon thread with a short timeout; all transport errors are swallowed so
|
|
14
|
+
monitoring never breaks or slows the host application.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
from .client import client
|
|
22
|
+
|
|
23
|
+
__all__ = ["init", "capture_exception", "flush"]
|
|
24
|
+
__version__ = "0.1.0"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def init(
|
|
28
|
+
api_key: Optional[str] = None,
|
|
29
|
+
endpoint: Optional[str] = None,
|
|
30
|
+
flush_interval_ms: Optional[int] = None,
|
|
31
|
+
enabled: Optional[bool] = None,
|
|
32
|
+
timeout_ms: Optional[int] = None,
|
|
33
|
+
max_batch: Optional[int] = None,
|
|
34
|
+
capture_source: Optional[bool] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialise the SDK and install auto-instrumentation. Idempotent.
|
|
37
|
+
|
|
38
|
+
Any argument left as ``None`` falls back to an environment variable
|
|
39
|
+
(``METRIXWIRE_KEY``, ``METRIXWIRE_ENDPOINT``, ``METRIXWIRE_ENABLED``) and
|
|
40
|
+
then a default. A missing API key runs the SDK disabled rather than raising.
|
|
41
|
+
"""
|
|
42
|
+
client.init(
|
|
43
|
+
api_key=api_key,
|
|
44
|
+
endpoint=endpoint,
|
|
45
|
+
flush_interval_ms=flush_interval_ms,
|
|
46
|
+
enabled=enabled,
|
|
47
|
+
timeout_ms=timeout_ms,
|
|
48
|
+
max_batch=max_batch,
|
|
49
|
+
capture_source=capture_source,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def capture_exception(exc: BaseException) -> None:
|
|
54
|
+
"""Attach an exception to the active trace and flag it as an error.
|
|
55
|
+
|
|
56
|
+
The single manual escape hatch — for frameworks that catch their own errors
|
|
57
|
+
before the SDK can observe them. Never throws.
|
|
58
|
+
"""
|
|
59
|
+
client.capture_exception(exc)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def flush() -> None:
|
|
63
|
+
"""Flush any queued traces now (e.g. before a short-lived process exits)."""
|
|
64
|
+
client.flush()
|
metrixwire/client.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The SDK singleton + the internal trace lifecycle helpers used by patches.
|
|
2
|
+
|
|
3
|
+
The SDK is zero-config: ``metrixwire.init()`` is called once and every request,
|
|
4
|
+
database query, cache op and outbound HTTP call is instrumented automatically.
|
|
5
|
+
There is no manual span API on the public surface — only a
|
|
6
|
+
``capture_exception()`` escape hatch (mirroring Node/PHP) for frameworks that
|
|
7
|
+
swallow their own errors. Everything here is defensive: instrumentation must
|
|
8
|
+
never throw into the host application.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Callable, Dict, Optional
|
|
15
|
+
|
|
16
|
+
from .config import Config, build_config
|
|
17
|
+
from .context import get_current_trace, reset_current_trace, set_current_trace
|
|
18
|
+
from .transport import Transport
|
|
19
|
+
from .trace import Span, Trace, exception_meta, iso_millis
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _Client:
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self.config: Optional[Config] = None
|
|
25
|
+
self.transport: Optional[Transport] = None
|
|
26
|
+
|
|
27
|
+
def init(self, **opts: Any) -> None:
|
|
28
|
+
if self.config is not None:
|
|
29
|
+
return # already initialised — no-op
|
|
30
|
+
self.config = build_config(**opts)
|
|
31
|
+
self.transport = Transport(self.config)
|
|
32
|
+
if self.config.enabled:
|
|
33
|
+
self.transport.start()
|
|
34
|
+
self._install_patches()
|
|
35
|
+
|
|
36
|
+
def _install_patches(self) -> None:
|
|
37
|
+
# Import lazily so a broken/optional patch can never break init().
|
|
38
|
+
try:
|
|
39
|
+
from .patches import install_all
|
|
40
|
+
|
|
41
|
+
install_all(self)
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
# ── trace lifecycle (internal — called by patches) ───────────────────────
|
|
46
|
+
|
|
47
|
+
def start_trace(self, route: str, method: Optional[str]) -> Optional[Trace]:
|
|
48
|
+
if not self._enabled():
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
now = time.time()
|
|
52
|
+
trace = Trace(
|
|
53
|
+
route=route,
|
|
54
|
+
method=method,
|
|
55
|
+
started_at=iso_millis(now),
|
|
56
|
+
start_monotonic=time.monotonic(),
|
|
57
|
+
)
|
|
58
|
+
token = set_current_trace(trace)
|
|
59
|
+
# Stash the reset token on the trace so finish_trace can restore.
|
|
60
|
+
trace.meta.setdefault("__internal__", {})
|
|
61
|
+
trace.meta["__internal__"]["token"] = token
|
|
62
|
+
return trace
|
|
63
|
+
except Exception:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def finish_trace(
|
|
67
|
+
self,
|
|
68
|
+
trace: Optional[Trace],
|
|
69
|
+
status_code: int = 200,
|
|
70
|
+
response_bytes: int = 0,
|
|
71
|
+
memory_mb: int = 0,
|
|
72
|
+
) -> None:
|
|
73
|
+
if trace is None or not self._enabled():
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
trace.duration_ms = int(round((time.monotonic() - trace.start_monotonic) * 1000))
|
|
77
|
+
# captureException may have already flagged this as an error — keep it.
|
|
78
|
+
if status_code >= 500:
|
|
79
|
+
trace.status = "error"
|
|
80
|
+
if response_bytes and response_bytes > 0:
|
|
81
|
+
trace.meta["responseBytes"] = int(response_bytes)
|
|
82
|
+
if memory_mb and memory_mb > 0:
|
|
83
|
+
trace.meta["memoryMb"] = int(memory_mb)
|
|
84
|
+
self._enqueue(trace)
|
|
85
|
+
finally:
|
|
86
|
+
self._pop_trace(trace)
|
|
87
|
+
|
|
88
|
+
def _pop_trace(self, trace: Trace) -> None:
|
|
89
|
+
try:
|
|
90
|
+
internal = trace.meta.pop("__internal__", None)
|
|
91
|
+
token = internal.get("token") if internal else None
|
|
92
|
+
if token is not None:
|
|
93
|
+
reset_current_trace(token)
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def _enqueue(self, trace: Trace) -> None:
|
|
98
|
+
try:
|
|
99
|
+
# Strip internal bookkeeping before serializing.
|
|
100
|
+
trace.meta.pop("__internal__", None)
|
|
101
|
+
if self.transport is not None:
|
|
102
|
+
self.transport.enqueue(trace.to_dict())
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
# ── span recording (internal — called by patches) ────────────────────────
|
|
107
|
+
|
|
108
|
+
def record_span(
|
|
109
|
+
self,
|
|
110
|
+
type: str,
|
|
111
|
+
description: str,
|
|
112
|
+
duration_ms: float,
|
|
113
|
+
source_location: Optional[str] = None,
|
|
114
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
if not self._enabled() or not description:
|
|
117
|
+
return
|
|
118
|
+
trace = get_current_trace()
|
|
119
|
+
if trace is None:
|
|
120
|
+
return
|
|
121
|
+
try:
|
|
122
|
+
now = time.time()
|
|
123
|
+
started_at = iso_millis(now - (duration_ms / 1000.0))
|
|
124
|
+
trace.add_span(
|
|
125
|
+
Span(
|
|
126
|
+
type=type,
|
|
127
|
+
description=description,
|
|
128
|
+
started_at=started_at,
|
|
129
|
+
duration_ms=int(round(max(0.0, duration_ms))),
|
|
130
|
+
source_location=source_location,
|
|
131
|
+
meta=meta or None,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
def capture_exception(self, exc: BaseException) -> None:
|
|
138
|
+
"""Attach an exception to the active trace and flag it as an error.
|
|
139
|
+
|
|
140
|
+
The one public escape hatch — for frameworks that catch their own
|
|
141
|
+
errors before our patch can observe them. Never throws.
|
|
142
|
+
"""
|
|
143
|
+
if not self._enabled():
|
|
144
|
+
return
|
|
145
|
+
trace = get_current_trace()
|
|
146
|
+
if trace is None or exc is None:
|
|
147
|
+
return
|
|
148
|
+
try:
|
|
149
|
+
em = exception_meta(exc)
|
|
150
|
+
if em:
|
|
151
|
+
trace.meta["exception"] = em
|
|
152
|
+
trace.status = "error"
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
def flush(self) -> None:
|
|
157
|
+
try:
|
|
158
|
+
if self.transport is not None:
|
|
159
|
+
self.transport.flush()
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
# ── helpers ──────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
def _enabled(self) -> bool:
|
|
166
|
+
return bool(self.config and self.config.enabled)
|
|
167
|
+
|
|
168
|
+
def is_source_capture_on(self) -> bool:
|
|
169
|
+
return bool(self.config.capture_source) if self.config else True
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Singleton — the SDK's internal surface (patches call into this).
|
|
173
|
+
client = _Client()
|
metrixwire/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""SDK configuration + env fallbacks.
|
|
2
|
+
|
|
3
|
+
``init()`` reads these env vars when the matching argument is omitted:
|
|
4
|
+
``METRIXWIRE_KEY``, ``METRIXWIRE_ENDPOINT``, ``METRIXWIRE_ENABLED``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
DEFAULT_ENDPOINT = "http://localhost:3000/ingest"
|
|
14
|
+
DEFAULT_FLUSH_INTERVAL_MS = 5000
|
|
15
|
+
DEFAULT_TIMEOUT_MS = 3000
|
|
16
|
+
DEFAULT_MAX_BATCH = 20
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _env(key: str) -> Optional[str]:
|
|
20
|
+
v = os.environ.get(key)
|
|
21
|
+
if v is None or v == "":
|
|
22
|
+
return None
|
|
23
|
+
return v
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _env_bool(key: str) -> Optional[bool]:
|
|
27
|
+
v = _env(key)
|
|
28
|
+
if v is None:
|
|
29
|
+
return None
|
|
30
|
+
return v.strip().lower() in ("1", "true", "yes", "on")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_endpoint(endpoint: str) -> str:
|
|
34
|
+
"""Accept a base URL or the full ``/ingest`` URL.
|
|
35
|
+
|
|
36
|
+
If it doesn't already end with ``/ingest``, append it — identical to the
|
|
37
|
+
Node/PHP transports.
|
|
38
|
+
"""
|
|
39
|
+
url = (endpoint or DEFAULT_ENDPOINT).rstrip("/")
|
|
40
|
+
if not url.endswith("/ingest"):
|
|
41
|
+
url += "/ingest"
|
|
42
|
+
return url
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Config:
|
|
47
|
+
api_key: str
|
|
48
|
+
endpoint: str
|
|
49
|
+
flush_interval_ms: int
|
|
50
|
+
enabled: bool
|
|
51
|
+
timeout_ms: int
|
|
52
|
+
max_batch: int
|
|
53
|
+
capture_source: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def build_config(
|
|
57
|
+
api_key: Optional[str] = None,
|
|
58
|
+
endpoint: Optional[str] = None,
|
|
59
|
+
flush_interval_ms: Optional[int] = None,
|
|
60
|
+
enabled: Optional[bool] = None,
|
|
61
|
+
timeout_ms: Optional[int] = None,
|
|
62
|
+
max_batch: Optional[int] = None,
|
|
63
|
+
capture_source: Optional[bool] = None,
|
|
64
|
+
) -> Config:
|
|
65
|
+
"""Merge explicit args, env fallbacks and defaults into a Config.
|
|
66
|
+
|
|
67
|
+
A missing API key means the SDK runs disabled (never throws) — matching the
|
|
68
|
+
other SDKs.
|
|
69
|
+
"""
|
|
70
|
+
key = api_key if api_key is not None else (_env("METRIXWIRE_KEY") or "")
|
|
71
|
+
ep = endpoint if endpoint is not None else _env("METRIXWIRE_ENDPOINT")
|
|
72
|
+
want_enabled = enabled if enabled is not None else _env_bool("METRIXWIRE_ENABLED")
|
|
73
|
+
if want_enabled is None:
|
|
74
|
+
want_enabled = True
|
|
75
|
+
|
|
76
|
+
return Config(
|
|
77
|
+
api_key=key or "",
|
|
78
|
+
endpoint=normalize_endpoint(ep or DEFAULT_ENDPOINT),
|
|
79
|
+
flush_interval_ms=int(flush_interval_ms if flush_interval_ms is not None else DEFAULT_FLUSH_INTERVAL_MS),
|
|
80
|
+
# Disabled unless we actually have a key — never crash on a missing key.
|
|
81
|
+
enabled=bool(want_enabled) and bool(key),
|
|
82
|
+
timeout_ms=int(timeout_ms if timeout_ms is not None else DEFAULT_TIMEOUT_MS),
|
|
83
|
+
max_batch=int(max_batch if max_batch is not None else DEFAULT_MAX_BATCH),
|
|
84
|
+
capture_source=bool(capture_source) if capture_source is not None else True,
|
|
85
|
+
)
|
metrixwire/context.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""The active-trace store, backed by :mod:`contextvars`.
|
|
2
|
+
|
|
3
|
+
``contextvars`` gives us the right isolation semantics automatically: each
|
|
4
|
+
thread and each asyncio task sees its own current trace, so concurrent requests
|
|
5
|
+
(sync WSGI worker threads or async ASGI tasks) never bleed spans into each
|
|
6
|
+
other. All accessors are defensive — instrumentation must never throw.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import contextvars
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from .trace import Trace
|
|
15
|
+
|
|
16
|
+
_current_trace: "contextvars.ContextVar[Optional[Trace]]" = contextvars.ContextVar(
|
|
17
|
+
"metrixwire_current_trace", default=None
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_current_trace() -> Optional[Trace]:
|
|
22
|
+
try:
|
|
23
|
+
return _current_trace.get()
|
|
24
|
+
except Exception:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_current_trace(trace: Optional[Trace]) -> "contextvars.Token":
|
|
29
|
+
return _current_trace.set(trace)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def reset_current_trace(token: "contextvars.Token") -> None:
|
|
33
|
+
try:
|
|
34
|
+
_current_trace.reset(token)
|
|
35
|
+
except Exception:
|
|
36
|
+
pass
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Auto-instrumentation registry.
|
|
2
|
+
|
|
3
|
+
``install_all`` runs every patch in its own ``try/except`` so one broken or
|
|
4
|
+
missing target can never stop the others — and never breaks ``init()``. Each
|
|
5
|
+
patch is idempotent (guards against double-patching) and skips silently when its
|
|
6
|
+
target library isn't importable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Callable, List
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def install_all(client: Any) -> None:
|
|
15
|
+
patchers: List[Callable[[Any], None]] = []
|
|
16
|
+
|
|
17
|
+
# Web frameworks (open one trace per request).
|
|
18
|
+
from . import flask_patch, django_patch, starlette_patch
|
|
19
|
+
|
|
20
|
+
patchers += [
|
|
21
|
+
flask_patch.install,
|
|
22
|
+
django_patch.install,
|
|
23
|
+
starlette_patch.install,
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
# DB drivers (db_query spans).
|
|
27
|
+
from . import sqlite3_patch, psycopg2_patch, psycopg3_patch, pymysql_patch, mysqldb_patch
|
|
28
|
+
|
|
29
|
+
patchers += [
|
|
30
|
+
sqlite3_patch.install,
|
|
31
|
+
psycopg2_patch.install,
|
|
32
|
+
psycopg3_patch.install,
|
|
33
|
+
pymysql_patch.install,
|
|
34
|
+
mysqldb_patch.install,
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Outbound HTTP (http_call spans).
|
|
38
|
+
from . import requests_patch, httpclient_patch
|
|
39
|
+
|
|
40
|
+
patchers += [requests_patch.install, httpclient_patch.install]
|
|
41
|
+
|
|
42
|
+
# Cache (custom span, kind=cache).
|
|
43
|
+
from . import redis_patch
|
|
44
|
+
|
|
45
|
+
patchers += [redis_patch.install]
|
|
46
|
+
|
|
47
|
+
for patcher in patchers:
|
|
48
|
+
try:
|
|
49
|
+
patcher(client)
|
|
50
|
+
except Exception:
|
|
51
|
+
# A single failing/absent target must never break the others.
|
|
52
|
+
pass
|
metrixwire/patches/db.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Shared helpers for the DB-driver patches.
|
|
2
|
+
|
|
3
|
+
All drivers follow the DB-API 2.0 cursor protocol, so the timing / span-shape /
|
|
4
|
+
transaction-detection logic is identical: time the ``execute``, read
|
|
5
|
+
``cursor.rowcount``, capture the nearest user frame, and — when the statement is
|
|
6
|
+
a ``COMMIT`` / ``BEGIN`` boundary — additionally emit a transaction span.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from ..util import nearest_user_frame
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_sql(sql: Any) -> str:
|
|
18
|
+
try:
|
|
19
|
+
text = sql.decode("utf-8", "replace") if isinstance(sql, (bytes, bytearray)) else str(sql)
|
|
20
|
+
return " ".join(text.split()).strip()
|
|
21
|
+
except Exception:
|
|
22
|
+
return ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def record_query(client: Any, sql: Any, duration_ms: float, rowcount: Optional[int]) -> None:
|
|
26
|
+
"""Emit a ``db_query`` span for one executed statement."""
|
|
27
|
+
text = _normalize_sql(sql)
|
|
28
|
+
if not text:
|
|
29
|
+
return
|
|
30
|
+
meta = None
|
|
31
|
+
try:
|
|
32
|
+
if rowcount is not None and int(rowcount) >= 0:
|
|
33
|
+
meta = {"rowCount": int(rowcount)}
|
|
34
|
+
except Exception:
|
|
35
|
+
meta = None
|
|
36
|
+
source = nearest_user_frame() if client.is_source_capture_on() else None
|
|
37
|
+
client.record_span("db_query", text, duration_ms, source_location=source, meta=meta)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Per-connection transaction start time (monotonic). Keyed by id() of the
|
|
41
|
+
# connection so we can time a BEGIN…COMMIT window opened via raw SQL. Weakly
|
|
42
|
+
# scoped by best-effort cleanup on COMMIT/ROLLBACK.
|
|
43
|
+
_txn_starts: "dict[int, float]" = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _txn_boundary(client: Any, conn_id: Optional[int], sql_text: str) -> None:
|
|
47
|
+
"""Detect BEGIN / COMMIT / ROLLBACK issued as raw SQL and time the window.
|
|
48
|
+
|
|
49
|
+
Emits a ``custom`` transaction span (``meta.kind='transaction'``) when a
|
|
50
|
+
transaction closes, so the long_transaction detector can flag it.
|
|
51
|
+
"""
|
|
52
|
+
if conn_id is None:
|
|
53
|
+
return
|
|
54
|
+
try:
|
|
55
|
+
head = sql_text.lstrip().upper()
|
|
56
|
+
if head.startswith("BEGIN") or head.startswith("START TRANSACTION"):
|
|
57
|
+
_txn_starts[conn_id] = time.monotonic()
|
|
58
|
+
elif head.startswith("COMMIT") or head.startswith("ROLLBACK") or head.startswith("END"):
|
|
59
|
+
start = _txn_starts.pop(conn_id, None)
|
|
60
|
+
if start is not None:
|
|
61
|
+
duration_ms = (time.monotonic() - start) * 1000.0
|
|
62
|
+
client.record_span(
|
|
63
|
+
"custom", "DB transaction", duration_ms, meta={"kind": "transaction"}
|
|
64
|
+
)
|
|
65
|
+
except Exception:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _conn_id(cursor: Any) -> Optional[int]:
|
|
70
|
+
try:
|
|
71
|
+
conn = getattr(cursor, "connection", None)
|
|
72
|
+
return id(conn) if conn is not None else None
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def instrument_execute(client: Any, cursor: Any, original: Any, sql: Any, *args: Any, **kwargs: Any) -> Any:
|
|
78
|
+
"""Time a cursor.execute/executemany call and record its span."""
|
|
79
|
+
start = time.monotonic()
|
|
80
|
+
try:
|
|
81
|
+
return original(cursor, sql, *args, **kwargs)
|
|
82
|
+
finally:
|
|
83
|
+
try:
|
|
84
|
+
duration_ms = (time.monotonic() - start) * 1000.0
|
|
85
|
+
rowcount = getattr(cursor, "rowcount", None)
|
|
86
|
+
record_query(client, sql, duration_ms, rowcount)
|
|
87
|
+
_txn_boundary(client, _conn_id(cursor), _normalize_sql(sql))
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def instrument_commit(client: Any, conn: Any, original: Any, *args: Any, **kwargs: Any) -> Any:
|
|
93
|
+
"""Wrap ``connection.commit()`` to time a transaction opened via the driver's
|
|
94
|
+
implicit/explicit begin. Emits a transaction span on commit."""
|
|
95
|
+
conn_id = id(conn)
|
|
96
|
+
start = _txn_starts.pop(conn_id, None)
|
|
97
|
+
result = original(conn, *args, **kwargs)
|
|
98
|
+
try:
|
|
99
|
+
if start is not None:
|
|
100
|
+
duration_ms = (time.monotonic() - start) * 1000.0
|
|
101
|
+
client.record_span("custom", "DB transaction", duration_ms, meta={"kind": "transaction"})
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def mark_txn_begin(conn: Any) -> None:
|
|
108
|
+
try:
|
|
109
|
+
_txn_starts.setdefault(id(conn), time.monotonic())
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Django auto-instrumentation.
|
|
2
|
+
|
|
3
|
+
We patch the WSGI and ASGI handler entry points
|
|
4
|
+
(``WSGIHandler.__call__`` / ``ASGIHandler.__call__``) so a trace is opened per
|
|
5
|
+
request. The route is refined to the resolver match (e.g. ``users/<int:id>``)
|
|
6
|
+
via ``request.resolver_match`` when Django has resolved it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ..util import memory_delta_mb, memory_snapshot
|
|
14
|
+
from . import web
|
|
15
|
+
|
|
16
|
+
_WSGI_PATCHED = False
|
|
17
|
+
_ASGI_PATCHED = False
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def install(client: Any) -> None:
|
|
21
|
+
_install_wsgi(client)
|
|
22
|
+
_install_asgi(client)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _route_from_path(method: str, path: str) -> str:
|
|
26
|
+
"""Resolve the URLconf pattern for a path (e.g. ``users/<int:id>/``).
|
|
27
|
+
|
|
28
|
+
Independent of the request object — resolves the path directly against
|
|
29
|
+
Django's resolver, which is stable and always available after setup.
|
|
30
|
+
"""
|
|
31
|
+
try:
|
|
32
|
+
from django.urls import resolve
|
|
33
|
+
|
|
34
|
+
match = resolve(path)
|
|
35
|
+
route = getattr(match, "route", None)
|
|
36
|
+
if route:
|
|
37
|
+
return web.route_label(method, "/" + str(route).lstrip("/"))
|
|
38
|
+
except Exception:
|
|
39
|
+
pass
|
|
40
|
+
return web.route_label(method, path)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _install_wsgi(client: Any) -> None:
|
|
44
|
+
global _WSGI_PATCHED
|
|
45
|
+
if _WSGI_PATCHED:
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
from django.core.handlers.wsgi import WSGIHandler
|
|
49
|
+
except Exception:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if getattr(WSGIHandler.__call__, "_metrixwire", False):
|
|
53
|
+
_WSGI_PATCHED = True
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
original = WSGIHandler.__call__
|
|
57
|
+
|
|
58
|
+
def __call__(self, environ, start_response): # type: ignore[no-untyped-def]
|
|
59
|
+
method = environ.get("REQUEST_METHOD", "GET")
|
|
60
|
+
path = web.path_only(environ.get("PATH_INFO") or "/")
|
|
61
|
+
trace = client.start_trace(web.route_label(method, path), method)
|
|
62
|
+
if trace is None:
|
|
63
|
+
return original(self, environ, start_response)
|
|
64
|
+
|
|
65
|
+
start_mem = memory_snapshot()
|
|
66
|
+
state = {"status_code": 200, "bytes": 0}
|
|
67
|
+
|
|
68
|
+
def wrapped_start_response(status, headers, exc_info=None): # type: ignore[no-untyped-def]
|
|
69
|
+
try:
|
|
70
|
+
state["status_code"] = int(str(status).split(" ", 1)[0])
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
return start_response(status, headers, exc_info)
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
response = original(self, environ, wrapped_start_response)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
client.capture_exception(exc)
|
|
79
|
+
client.finish_trace(trace, status_code=500, memory_mb=memory_delta_mb(start_mem))
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
# Django returns an HttpResponse (iterable). Refine the route from it.
|
|
83
|
+
try:
|
|
84
|
+
status_code = int(getattr(response, "status_code", state["status_code"]))
|
|
85
|
+
except Exception:
|
|
86
|
+
status_code = state["status_code"]
|
|
87
|
+
try:
|
|
88
|
+
content = getattr(response, "content", b"")
|
|
89
|
+
byte_len = len(content) if content is not None else 0
|
|
90
|
+
except Exception:
|
|
91
|
+
byte_len = 0
|
|
92
|
+
|
|
93
|
+
trace.route = _route_from_path(method, path)
|
|
94
|
+
client.finish_trace(
|
|
95
|
+
trace, status_code=status_code, response_bytes=byte_len,
|
|
96
|
+
memory_mb=memory_delta_mb(start_mem),
|
|
97
|
+
)
|
|
98
|
+
return response
|
|
99
|
+
|
|
100
|
+
__call__._metrixwire = True # type: ignore[attr-defined]
|
|
101
|
+
WSGIHandler.__call__ = __call__
|
|
102
|
+
_WSGI_PATCHED = True
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _install_asgi(client: Any) -> None:
|
|
106
|
+
global _ASGI_PATCHED
|
|
107
|
+
if _ASGI_PATCHED:
|
|
108
|
+
return
|
|
109
|
+
try:
|
|
110
|
+
from django.core.handlers.asgi import ASGIHandler
|
|
111
|
+
except Exception:
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if getattr(ASGIHandler.__call__, "_metrixwire", False):
|
|
115
|
+
_ASGI_PATCHED = True
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
original = ASGIHandler.__call__
|
|
119
|
+
|
|
120
|
+
async def __call__(self, scope, receive, send): # type: ignore[no-untyped-def]
|
|
121
|
+
if scope.get("type") != "http":
|
|
122
|
+
return await original(self, scope, receive, send)
|
|
123
|
+
|
|
124
|
+
method = scope.get("method", "GET")
|
|
125
|
+
path = web.path_only(scope.get("path") or "/")
|
|
126
|
+
trace = client.start_trace(web.route_label(method, path), method)
|
|
127
|
+
if trace is None:
|
|
128
|
+
return await original(self, scope, receive, send)
|
|
129
|
+
|
|
130
|
+
start_mem = memory_snapshot()
|
|
131
|
+
state = {"status_code": 200, "bytes": 0}
|
|
132
|
+
|
|
133
|
+
async def wrapped_send(message): # type: ignore[no-untyped-def]
|
|
134
|
+
try:
|
|
135
|
+
mtype = message.get("type")
|
|
136
|
+
if mtype == "http.response.start":
|
|
137
|
+
state["status_code"] = int(message.get("status", 200))
|
|
138
|
+
elif mtype == "http.response.body":
|
|
139
|
+
body = message.get("body") or b""
|
|
140
|
+
try:
|
|
141
|
+
state["bytes"] += len(body)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
except Exception:
|
|
145
|
+
pass
|
|
146
|
+
return await send(message)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
result = await original(self, scope, receive, wrapped_send)
|
|
150
|
+
trace.route = _route_from_path(method, path)
|
|
151
|
+
client.finish_trace(
|
|
152
|
+
trace, status_code=state["status_code"], response_bytes=state["bytes"],
|
|
153
|
+
memory_mb=memory_delta_mb(start_mem),
|
|
154
|
+
)
|
|
155
|
+
return result
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
client.capture_exception(exc)
|
|
158
|
+
trace.route = _route_from_path(method, path)
|
|
159
|
+
client.finish_trace(trace, status_code=500, memory_mb=memory_delta_mb(start_mem))
|
|
160
|
+
raise
|
|
161
|
+
|
|
162
|
+
__call__._metrixwire = True # type: ignore[attr-defined]
|
|
163
|
+
ASGIHandler.__call__ = __call__
|
|
164
|
+
_ASGI_PATCHED = True
|