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.
- metrixwire-0.2.1/.gitignore +14 -0
- metrixwire-0.2.1/PKG-INFO +135 -0
- metrixwire-0.2.1/README.md +113 -0
- metrixwire-0.2.1/pyproject.toml +40 -0
- metrixwire-0.2.1/src/metrixwire/__init__.py +64 -0
- metrixwire-0.2.1/src/metrixwire/client.py +173 -0
- metrixwire-0.2.1/src/metrixwire/config.py +85 -0
- metrixwire-0.2.1/src/metrixwire/context.py +36 -0
- metrixwire-0.2.1/src/metrixwire/patches/__init__.py +52 -0
- metrixwire-0.2.1/src/metrixwire/patches/db.py +111 -0
- metrixwire-0.2.1/src/metrixwire/patches/django_patch.py +164 -0
- metrixwire-0.2.1/src/metrixwire/patches/flask_patch.py +105 -0
- metrixwire-0.2.1/src/metrixwire/patches/httpclient_patch.py +74 -0
- metrixwire-0.2.1/src/metrixwire/patches/mysqldb_patch.py +46 -0
- metrixwire-0.2.1/src/metrixwire/patches/psycopg2_patch.py +64 -0
- metrixwire-0.2.1/src/metrixwire/patches/psycopg3_patch.py +61 -0
- metrixwire-0.2.1/src/metrixwire/patches/pymysql_patch.py +46 -0
- metrixwire-0.2.1/src/metrixwire/patches/redis_patch.py +57 -0
- metrixwire-0.2.1/src/metrixwire/patches/requests_patch.py +60 -0
- metrixwire-0.2.1/src/metrixwire/patches/sqlite3_patch.py +62 -0
- metrixwire-0.2.1/src/metrixwire/patches/starlette_patch.py +88 -0
- metrixwire-0.2.1/src/metrixwire/patches/web.py +35 -0
- metrixwire-0.2.1/src/metrixwire/trace.py +122 -0
- metrixwire-0.2.1/src/metrixwire/transport.py +127 -0
- metrixwire-0.2.1/src/metrixwire/util.py +95 -0
- metrixwire-0.2.1/src/metrixwire/wsgi.py +74 -0
|
@@ -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
|