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