metrixwire 0.2.1__tar.gz

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,14 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .env.local
5
+ *.log
6
+ .DS_Store
7
+ apps/web/dist/
8
+ .drizzle/
9
+ vendor/
10
+ sdks/php/composer.lock
11
+
12
+ # Python bytecode cache
13
+ __pycache__/
14
+ *.pyc
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.4
2
+ Name: metrixwire
3
+ Version: 0.2.1
4
+ Summary: Zero-config APM SDK for Python. Call init() once — every request, query, cache op and outbound HTTP call is instrumented automatically.
5
+ Project-URL: Homepage, https://metrixwire.com
6
+ Author: MetrixWire
7
+ License: MIT
8
+ Keywords: apm,monitoring,observability,tracing,zero-config
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: System :: Monitoring
12
+ Requires-Python: >=3.8
13
+ Provides-Extra: dev
14
+ Requires-Dist: django; extra == 'dev'
15
+ Requires-Dist: fastapi; extra == 'dev'
16
+ Requires-Dist: flask; extra == 'dev'
17
+ Requires-Dist: psycopg2-binary; extra == 'dev'
18
+ Requires-Dist: redis; extra == 'dev'
19
+ Requires-Dist: requests; extra == 'dev'
20
+ Requires-Dist: starlette; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # metrixwire (Python)
24
+
25
+ Zero-config APM SDK for **Python**. Call `init()` once — every request, database query, cache op and outbound HTTP call is instrumented automatically. There is no manual span API and no middleware to wire up. Non-blocking: if the MetrixWire endpoint is down, your app keeps running normally.
26
+
27
+ ## Installation
28
+
29
+ ```bash
30
+ pip install metrixwire
31
+ ```
32
+
33
+ Zero required dependencies — the core uses only the standard library.
34
+
35
+ ## Usage
36
+
37
+ ```python
38
+ import metrixwire
39
+
40
+ metrixwire.init(api_key="mw_...")
41
+ ```
42
+
43
+ That's it. Do this once, as early as possible in your process (before your server starts). Every HTTP request becomes a **trace**, and every query / HTTP call / cache op within it becomes a **span**.
44
+
45
+ ### Auto-init as early as possible
46
+
47
+ Import and init at the very top of your entry module (or a `sitecustomize.py` on your `PYTHONPATH`) so the patches are installed before your framework and drivers are used:
48
+
49
+ ```python
50
+ # sitecustomize.py — or the first lines of your app's entry point
51
+ import metrixwire
52
+ metrixwire.init() # reads METRIXWIRE_KEY / METRIXWIRE_ENDPOINT / METRIXWIRE_ENABLED from env
53
+ ```
54
+
55
+ With no arguments, `init()` reads its config from the environment:
56
+
57
+ | Env var | Purpose |
58
+ |---|---|
59
+ | `METRIXWIRE_KEY` | Project API key (required to enable) |
60
+ | `METRIXWIRE_ENDPOINT` | Ingest URL — base or full `/ingest` (default `http://localhost:3000/ingest`) |
61
+ | `METRIXWIRE_ENABLED` | `false` to disable entirely |
62
+
63
+ A missing API key runs the SDK **disabled** — it never raises.
64
+
65
+ ## How the automatic tracing works
66
+
67
+ A trace is opened for **every incoming request** by patching each framework's single entry point — no middleware, no per-route setup:
68
+
69
+ | Framework | Traced automatically | How |
70
+ |---|---|---|
71
+ | **Flask** | ✅ | wraps `Flask.wsgi_app`; route from the matched URL rule (`/users/<id>`) |
72
+ | **Django** | ✅ | patches `WSGIHandler.__call__` and `ASGIHandler.__call__`; route from the resolver match |
73
+ | **FastAPI · Starlette** | ✅ | wraps `Starlette.__call__` (ASGI); route from the matched pattern (`/users/{id}`) |
74
+ | **Bare / other WSGI** (`wsgiref`, …) | ✅ | wrap once with `metrixwire.wsgi.MetrixWireMiddleware` |
75
+
76
+ For a bare or unsupported WSGI app:
77
+
78
+ ```python
79
+ from metrixwire.wsgi import MetrixWireMiddleware
80
+ app = MetrixWireMiddleware(app)
81
+ ```
82
+
83
+ Each trace records the route, HTTP status, response byte size (for the large-response detector), memory growth (for the memory-spike detector), and any unhandled exception.
84
+
85
+ ## Automatically instrumented libraries
86
+
87
+ Installed via `init()` — each is best-effort: if the library isn't importable it's skipped silently.
88
+
89
+ | Library | Span | How |
90
+ |---|---|---|
91
+ | **psycopg2** | `db_query` (`rowCount`) | patches `psycopg2.extensions.cursor.execute`/`executemany` |
92
+ | **psycopg (v3)** | `db_query` (`rowCount`) | patches `psycopg.Cursor.execute`/`executemany` |
93
+ | **sqlite3** | `db_query` (`rowCount`) | patches `sqlite3.Cursor.execute`/`executemany` |
94
+ | **PyMySQL · mysqlclient** | `db_query` (`rowCount`) | patches the driver's `Cursor.execute`/`executemany` |
95
+ | **Django ORM · SQLAlchemy** | `db_query` | automatic — they run on the drivers above |
96
+ | **requests** | `http_call` (`statusCode`) | patches `Session.request` |
97
+ | **stdlib `http.client` / `urllib`** | `http_call` (`statusCode`) | patches `HTTPConnection.request`/`getresponse` |
98
+ | **redis-py** | cache (`custom`, `kind=cache`) | patches `Redis.execute_command` (records hit/miss) |
99
+ | **DB transactions** | `custom` (`kind=transaction`) | times `BEGIN…COMMIT` / `connection.commit()` |
100
+
101
+ Database spans also capture a `sourceLocation` (`file.py:42`) pointing at the nearest application frame.
102
+
103
+ ## `init` options
104
+
105
+ ```python
106
+ metrixwire.init(
107
+ api_key="mw_...", # required (or METRIXWIRE_KEY)
108
+ endpoint="http://localhost:3000/ingest", # default; base URL is accepted too
109
+ flush_interval_ms=5000, # how often batches are sent
110
+ enabled=True, # set False to disable entirely
111
+ timeout_ms=3000, # send timeout (short, non-blocking)
112
+ max_batch=20, # flush immediately once this many are queued
113
+ capture_source=True, # capture the file:line a span originated from
114
+ )
115
+ ```
116
+
117
+ ## Escape hatch
118
+
119
+ Requests, queries, HTTP calls and cache ops are all captured automatically. The one manual helper — for frameworks that catch their own errors before the SDK sees them — is:
120
+
121
+ ```python
122
+ import metrixwire
123
+
124
+ try:
125
+ ...
126
+ except Exception as e:
127
+ metrixwire.capture_exception(e) # attach it to the active trace
128
+ raise
129
+ ```
130
+
131
+ ## Non-blocking behavior
132
+
133
+ - Traces are batched and sent **off the request path** on a background daemon thread with a short timeout.
134
+ - **All** transport errors are swallowed — instrumentation never throws into your app.
135
+ - A final flush runs at process exit (`atexit`); you can also call `metrixwire.flush()` before a short-lived process exits.
@@ -0,0 +1,113 @@
1
+ # metrixwire (Python)
2
+
3
+ Zero-config APM SDK for **Python**. Call `init()` once — every request, database query, cache op and outbound HTTP call is instrumented automatically. There is no manual span API and no middleware to wire up. Non-blocking: if the MetrixWire endpoint is down, your app keeps running normally.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install metrixwire
9
+ ```
10
+
11
+ Zero required dependencies — the core uses only the standard library.
12
+
13
+ ## Usage
14
+
15
+ ```python
16
+ import metrixwire
17
+
18
+ metrixwire.init(api_key="mw_...")
19
+ ```
20
+
21
+ That's it. Do this once, as early as possible in your process (before your server starts). Every HTTP request becomes a **trace**, and every query / HTTP call / cache op within it becomes a **span**.
22
+
23
+ ### Auto-init as early as possible
24
+
25
+ Import and init at the very top of your entry module (or a `sitecustomize.py` on your `PYTHONPATH`) so the patches are installed before your framework and drivers are used:
26
+
27
+ ```python
28
+ # sitecustomize.py — or the first lines of your app's entry point
29
+ import metrixwire
30
+ metrixwire.init() # reads METRIXWIRE_KEY / METRIXWIRE_ENDPOINT / METRIXWIRE_ENABLED from env
31
+ ```
32
+
33
+ With no arguments, `init()` reads its config from the environment:
34
+
35
+ | Env var | Purpose |
36
+ |---|---|
37
+ | `METRIXWIRE_KEY` | Project API key (required to enable) |
38
+ | `METRIXWIRE_ENDPOINT` | Ingest URL — base or full `/ingest` (default `http://localhost:3000/ingest`) |
39
+ | `METRIXWIRE_ENABLED` | `false` to disable entirely |
40
+
41
+ A missing API key runs the SDK **disabled** — it never raises.
42
+
43
+ ## How the automatic tracing works
44
+
45
+ A trace is opened for **every incoming request** by patching each framework's single entry point — no middleware, no per-route setup:
46
+
47
+ | Framework | Traced automatically | How |
48
+ |---|---|---|
49
+ | **Flask** | ✅ | wraps `Flask.wsgi_app`; route from the matched URL rule (`/users/<id>`) |
50
+ | **Django** | ✅ | patches `WSGIHandler.__call__` and `ASGIHandler.__call__`; route from the resolver match |
51
+ | **FastAPI · Starlette** | ✅ | wraps `Starlette.__call__` (ASGI); route from the matched pattern (`/users/{id}`) |
52
+ | **Bare / other WSGI** (`wsgiref`, …) | ✅ | wrap once with `metrixwire.wsgi.MetrixWireMiddleware` |
53
+
54
+ For a bare or unsupported WSGI app:
55
+
56
+ ```python
57
+ from metrixwire.wsgi import MetrixWireMiddleware
58
+ app = MetrixWireMiddleware(app)
59
+ ```
60
+
61
+ Each trace records the route, HTTP status, response byte size (for the large-response detector), memory growth (for the memory-spike detector), and any unhandled exception.
62
+
63
+ ## Automatically instrumented libraries
64
+
65
+ Installed via `init()` — each is best-effort: if the library isn't importable it's skipped silently.
66
+
67
+ | Library | Span | How |
68
+ |---|---|---|
69
+ | **psycopg2** | `db_query` (`rowCount`) | patches `psycopg2.extensions.cursor.execute`/`executemany` |
70
+ | **psycopg (v3)** | `db_query` (`rowCount`) | patches `psycopg.Cursor.execute`/`executemany` |
71
+ | **sqlite3** | `db_query` (`rowCount`) | patches `sqlite3.Cursor.execute`/`executemany` |
72
+ | **PyMySQL · mysqlclient** | `db_query` (`rowCount`) | patches the driver's `Cursor.execute`/`executemany` |
73
+ | **Django ORM · SQLAlchemy** | `db_query` | automatic — they run on the drivers above |
74
+ | **requests** | `http_call` (`statusCode`) | patches `Session.request` |
75
+ | **stdlib `http.client` / `urllib`** | `http_call` (`statusCode`) | patches `HTTPConnection.request`/`getresponse` |
76
+ | **redis-py** | cache (`custom`, `kind=cache`) | patches `Redis.execute_command` (records hit/miss) |
77
+ | **DB transactions** | `custom` (`kind=transaction`) | times `BEGIN…COMMIT` / `connection.commit()` |
78
+
79
+ Database spans also capture a `sourceLocation` (`file.py:42`) pointing at the nearest application frame.
80
+
81
+ ## `init` options
82
+
83
+ ```python
84
+ metrixwire.init(
85
+ api_key="mw_...", # required (or METRIXWIRE_KEY)
86
+ endpoint="http://localhost:3000/ingest", # default; base URL is accepted too
87
+ flush_interval_ms=5000, # how often batches are sent
88
+ enabled=True, # set False to disable entirely
89
+ timeout_ms=3000, # send timeout (short, non-blocking)
90
+ max_batch=20, # flush immediately once this many are queued
91
+ capture_source=True, # capture the file:line a span originated from
92
+ )
93
+ ```
94
+
95
+ ## Escape hatch
96
+
97
+ Requests, queries, HTTP calls and cache ops are all captured automatically. The one manual helper — for frameworks that catch their own errors before the SDK sees them — is:
98
+
99
+ ```python
100
+ import metrixwire
101
+
102
+ try:
103
+ ...
104
+ except Exception as e:
105
+ metrixwire.capture_exception(e) # attach it to the active trace
106
+ raise
107
+ ```
108
+
109
+ ## Non-blocking behavior
110
+
111
+ - Traces are batched and sent **off the request path** on a background daemon thread with a short timeout.
112
+ - **All** transport errors are swallowed — instrumentation never throws into your app.
113
+ - A final flush runs at process exit (`atexit`); you can also call `metrixwire.flush()` before a short-lived process exits.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "metrixwire"
7
+ version = "0.2.1"
8
+ description = "Zero-config APM SDK for Python. Call init() once — every request, query, cache op and outbound HTTP call is instrumented automatically."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "MetrixWire" }]
13
+ keywords = ["apm", "observability", "tracing", "monitoring", "zero-config"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Intended Audience :: Developers",
17
+ "Topic :: System :: Monitoring",
18
+ ]
19
+ # The core has ZERO required runtime dependencies — it uses only the stdlib
20
+ # (urllib, threading, queue, contextvars). Instrumentation of third-party
21
+ # libraries is best-effort: if the library isn't importable, it's skipped.
22
+ dependencies = []
23
+
24
+ [project.optional-dependencies]
25
+ # Purely for exercising the demos / integration testing — never required.
26
+ dev = [
27
+ "flask",
28
+ "fastapi",
29
+ "starlette",
30
+ "django",
31
+ "psycopg2-binary",
32
+ "requests",
33
+ "redis",
34
+ ]
35
+
36
+ [project.urls]
37
+ Homepage = "https://metrixwire.com"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/metrixwire"]
@@ -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()
@@ -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()
@@ -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
+ )
@@ -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