nexusapm 1.0.0__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.
- nexusapm-1.0.0/LICENSE +21 -0
- nexusapm-1.0.0/PKG-INFO +102 -0
- nexusapm-1.0.0/README.md +70 -0
- nexusapm-1.0.0/nexusapm/__init__.py +346 -0
- nexusapm-1.0.0/nexusapm.egg-info/PKG-INFO +102 -0
- nexusapm-1.0.0/nexusapm.egg-info/SOURCES.txt +9 -0
- nexusapm-1.0.0/nexusapm.egg-info/dependency_links.txt +1 -0
- nexusapm-1.0.0/nexusapm.egg-info/requires.txt +4 -0
- nexusapm-1.0.0/nexusapm.egg-info/top_level.txt +1 -0
- nexusapm-1.0.0/setup.cfg +4 -0
- nexusapm-1.0.0/setup.py +28 -0
nexusapm-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NexusAPM
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
nexusapm-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nexusapm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: NexusAPM Python auto-instrumentation SDK (Flask / FastAPI)
|
|
5
|
+
Home-page: https://github.com/Intelli-APM/apm/tree/main/agent/nexusapm-python
|
|
6
|
+
Author: NexusAPM
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Framework :: Flask
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests
|
|
19
|
+
Provides-Extra: accurate-memory
|
|
20
|
+
Requires-Dist: psutil; extra == "accurate-memory"
|
|
21
|
+
Dynamic: author
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
Dynamic: provides-extra
|
|
29
|
+
Dynamic: requires-dist
|
|
30
|
+
Dynamic: requires-python
|
|
31
|
+
Dynamic: summary
|
|
32
|
+
|
|
33
|
+
# nexusapm (Python SDK)
|
|
34
|
+
|
|
35
|
+
One-line auto-instrumentation for Flask and FastAPI applications, reporting
|
|
36
|
+
to a [NexusAPM](https://github.com/Intelli-APM/apm) backend.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install nexusapm
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
**Flask:**
|
|
47
|
+
```python
|
|
48
|
+
import nexusapm
|
|
49
|
+
|
|
50
|
+
nexusapm.init(app, service_name="my-app",
|
|
51
|
+
backend_url="https://your-nexusapm-backend.example.com",
|
|
52
|
+
api_key="your-api-key") # or set NEXUSAPM_API_KEY in the environment
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**FastAPI:**
|
|
56
|
+
```python
|
|
57
|
+
import nexusapm
|
|
58
|
+
|
|
59
|
+
nexusapm.init(app, service_name="my-api", framework="fastapi",
|
|
60
|
+
backend_url="https://your-nexusapm-backend.example.com",
|
|
61
|
+
api_key="your-api-key")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
That's it — every request handled after this point is automatically traced.
|
|
65
|
+
|
|
66
|
+
## What gets captured automatically
|
|
67
|
+
|
|
68
|
+
- **Per request:** route, method, status, duration → sent as a trace
|
|
69
|
+
- **Per request:** response time → sent as a metric
|
|
70
|
+
- **Errors and slow requests** (4xx/5xx, or over 1s) → sent as log lines
|
|
71
|
+
- **Every 10 seconds:** app-level rollups — memory usage, average response
|
|
72
|
+
time, request throughput, error rate
|
|
73
|
+
|
|
74
|
+
## Distributed tracing across services
|
|
75
|
+
|
|
76
|
+
If this app calls another NexusAPM-instrumented service over HTTP (via the
|
|
77
|
+
`requests` library), the current request's trace ID is automatically
|
|
78
|
+
propagated onto that outgoing call — no extra code needed. The two
|
|
79
|
+
services' spans show up correlated under one trace in the NexusAPM
|
|
80
|
+
dashboard, instead of two disconnected traces.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
| Option | Required | Default | Notes |
|
|
85
|
+
|----------------|----------|----------------------------------|--------------------------------------------|
|
|
86
|
+
| `service_name` | No | `"unknown-python-service"` | Shows up as this service's name everywhere |
|
|
87
|
+
| `backend_url` | No | `http://localhost:3001` | Your NexusAPM backend's URL |
|
|
88
|
+
| `api_key` | No* | `NEXUSAPM_API_KEY` env var | *Required for any data to actually be accepted — falls back to the env var if not passed explicitly |
|
|
89
|
+
| `framework` | No | `"flask"` | Set to `"fastapi"` for FastAPI apps |
|
|
90
|
+
| `debug` | No | `False` | Logs `[NexusAPM]`-style debug lines |
|
|
91
|
+
|
|
92
|
+
## Optional: more accurate memory reporting
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install nexusapm[accurate-memory]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Uses `psutil` for real RSS memory instead of the standard-library fallback.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
nexusapm-1.0.0/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# nexusapm (Python SDK)
|
|
2
|
+
|
|
3
|
+
One-line auto-instrumentation for Flask and FastAPI applications, reporting
|
|
4
|
+
to a [NexusAPM](https://github.com/Intelli-APM/apm) backend.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install nexusapm
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Usage
|
|
13
|
+
|
|
14
|
+
**Flask:**
|
|
15
|
+
```python
|
|
16
|
+
import nexusapm
|
|
17
|
+
|
|
18
|
+
nexusapm.init(app, service_name="my-app",
|
|
19
|
+
backend_url="https://your-nexusapm-backend.example.com",
|
|
20
|
+
api_key="your-api-key") # or set NEXUSAPM_API_KEY in the environment
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**FastAPI:**
|
|
24
|
+
```python
|
|
25
|
+
import nexusapm
|
|
26
|
+
|
|
27
|
+
nexusapm.init(app, service_name="my-api", framework="fastapi",
|
|
28
|
+
backend_url="https://your-nexusapm-backend.example.com",
|
|
29
|
+
api_key="your-api-key")
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
That's it — every request handled after this point is automatically traced.
|
|
33
|
+
|
|
34
|
+
## What gets captured automatically
|
|
35
|
+
|
|
36
|
+
- **Per request:** route, method, status, duration → sent as a trace
|
|
37
|
+
- **Per request:** response time → sent as a metric
|
|
38
|
+
- **Errors and slow requests** (4xx/5xx, or over 1s) → sent as log lines
|
|
39
|
+
- **Every 10 seconds:** app-level rollups — memory usage, average response
|
|
40
|
+
time, request throughput, error rate
|
|
41
|
+
|
|
42
|
+
## Distributed tracing across services
|
|
43
|
+
|
|
44
|
+
If this app calls another NexusAPM-instrumented service over HTTP (via the
|
|
45
|
+
`requests` library), the current request's trace ID is automatically
|
|
46
|
+
propagated onto that outgoing call — no extra code needed. The two
|
|
47
|
+
services' spans show up correlated under one trace in the NexusAPM
|
|
48
|
+
dashboard, instead of two disconnected traces.
|
|
49
|
+
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
| Option | Required | Default | Notes |
|
|
53
|
+
|----------------|----------|----------------------------------|--------------------------------------------|
|
|
54
|
+
| `service_name` | No | `"unknown-python-service"` | Shows up as this service's name everywhere |
|
|
55
|
+
| `backend_url` | No | `http://localhost:3001` | Your NexusAPM backend's URL |
|
|
56
|
+
| `api_key` | No* | `NEXUSAPM_API_KEY` env var | *Required for any data to actually be accepted — falls back to the env var if not passed explicitly |
|
|
57
|
+
| `framework` | No | `"flask"` | Set to `"fastapi"` for FastAPI apps |
|
|
58
|
+
| `debug` | No | `False` | Logs `[NexusAPM]`-style debug lines |
|
|
59
|
+
|
|
60
|
+
## Optional: more accurate memory reporting
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install nexusapm[accurate-memory]
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Uses `psutil` for real RSS memory instead of the standard-library fallback.
|
|
67
|
+
|
|
68
|
+
## License
|
|
69
|
+
|
|
70
|
+
MIT
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""
|
|
2
|
+
nexusapm — Python auto-instrumentation SDK
|
|
3
|
+
============================================
|
|
4
|
+
One-line monitoring for Flask and FastAPI apps.
|
|
5
|
+
|
|
6
|
+
Usage (Flask):
|
|
7
|
+
import nexusapm
|
|
8
|
+
nexusapm.init(app, service_name="my-python-app")
|
|
9
|
+
|
|
10
|
+
Usage (FastAPI):
|
|
11
|
+
import nexusapm
|
|
12
|
+
nexusapm.init(app, service_name="my-api", framework="fastapi")
|
|
13
|
+
|
|
14
|
+
Captures automatically, per request:
|
|
15
|
+
- route, method, status, duration -> trace
|
|
16
|
+
- response_time -> metric
|
|
17
|
+
- errors (4xx/5xx) and slow (>1s) -> logs
|
|
18
|
+
And every 10s, app-level rollups:
|
|
19
|
+
- app_memory_mb, app_avg_response_time,
|
|
20
|
+
app_total_requests, app_error_rate
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
import threading
|
|
26
|
+
import atexit
|
|
27
|
+
import random
|
|
28
|
+
import string
|
|
29
|
+
import contextvars
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
|
|
32
|
+
import requests
|
|
33
|
+
|
|
34
|
+
TRACE_ID_HEADER = "X-NexusAPM-Trace-Id"
|
|
35
|
+
SPAN_ID_HEADER = "X-NexusAPM-Span-Id"
|
|
36
|
+
|
|
37
|
+
# Holds (trace_id, span_id) for whichever request is currently being
|
|
38
|
+
# handled on this thread/task — lets an outgoing `requests` call made deep
|
|
39
|
+
# inside a view function find "which request am I part of" automatically.
|
|
40
|
+
# Previously send_trace() always picked a brand-new random trace_id with no
|
|
41
|
+
# relation to any incoming request, so one logical request crossing
|
|
42
|
+
# multiple instrumented services produced N unrelated traces instead of a
|
|
43
|
+
# single correlated one — this is the fix for that.
|
|
44
|
+
_trace_ctx = contextvars.ContextVar("nexusapm_trace_ctx", default=None)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
import psutil
|
|
48
|
+
_HAS_PSUTIL = True
|
|
49
|
+
except Exception:
|
|
50
|
+
_HAS_PSUTIL = False
|
|
51
|
+
|
|
52
|
+
# ---- module state ----
|
|
53
|
+
_config = {
|
|
54
|
+
"service_name": "unknown-python-service",
|
|
55
|
+
"backend_url": "http://localhost:3001",
|
|
56
|
+
"api_key": "",
|
|
57
|
+
"debug": False,
|
|
58
|
+
}
|
|
59
|
+
_stats = {"requests": 0, "errors": 0, "total_duration": 0.0}
|
|
60
|
+
_lock = threading.Lock()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _now_iso():
|
|
64
|
+
return datetime.now(timezone.utc).isoformat()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _log(msg):
|
|
68
|
+
print(f"[NexusAPM] {msg}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _gen_id():
|
|
72
|
+
return "".join(random.choice(string.ascii_lowercase + string.digits) for _ in range(16))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---- senders (fire-and-forget, never block the app) ----
|
|
76
|
+
def _post(path, payload):
|
|
77
|
+
# All ingestion endpoints (/api/metrics, /api/logs, /api/traces) require
|
|
78
|
+
# an API key — this was missing entirely before, so every call from
|
|
79
|
+
# this SDK silently 401'd once those routes started requiring auth.
|
|
80
|
+
headers = {"x-api-key": _config["api_key"]} if _config["api_key"] else {}
|
|
81
|
+
|
|
82
|
+
def _send():
|
|
83
|
+
try:
|
|
84
|
+
requests.post(
|
|
85
|
+
f"{_config['backend_url']}{path}", json=payload, timeout=2, headers=headers
|
|
86
|
+
)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
if _config["debug"]:
|
|
89
|
+
_log(f"send {path} FAILED: {type(e).__name__}: {e}")
|
|
90
|
+
threading.Thread(target=_send, daemon=True).start()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Announces "the process I'm running in (this PID) calls itself
|
|
94
|
+
# service_name in its own trace data" — lets the backend link this SDK's
|
|
95
|
+
# traced name to whatever name Service Discovery's process scanner
|
|
96
|
+
# independently gave the same underlying process (e.g. "app" for
|
|
97
|
+
# `python app.py`), purely by matching PID. Sent once at startup and then
|
|
98
|
+
# repeated periodically, since Discovery's own scan might not have found
|
|
99
|
+
# this PID yet at the exact moment the SDK boots.
|
|
100
|
+
ANNOUNCE_INTERVAL_SECS = 60
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _announce_identity():
|
|
104
|
+
headers = {"x-api-key": _config["api_key"]} if _config["api_key"] else {}
|
|
105
|
+
|
|
106
|
+
def _send():
|
|
107
|
+
try:
|
|
108
|
+
requests.post(
|
|
109
|
+
f"{_config['backend_url']}/api/discovery/identify",
|
|
110
|
+
json={"pid": os.getpid(), "tracedServiceName": _config["service_name"]},
|
|
111
|
+
timeout=2, headers=headers,
|
|
112
|
+
)
|
|
113
|
+
except Exception as e:
|
|
114
|
+
if _config["debug"]:
|
|
115
|
+
_log(f"identity announce FAILED: {type(e).__name__}: {e}")
|
|
116
|
+
threading.Thread(target=_send, daemon=True).start()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _identity_reporter():
|
|
120
|
+
while True:
|
|
121
|
+
time.sleep(ANNOUNCE_INTERVAL_SECS)
|
|
122
|
+
_announce_identity()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def send_metric(name, value, unit=""):
|
|
126
|
+
_post("/api/metrics", {
|
|
127
|
+
"service": _config["service_name"],
|
|
128
|
+
"name": name,
|
|
129
|
+
"value": value,
|
|
130
|
+
"unit": unit,
|
|
131
|
+
"timestamp": _now_iso(),
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def send_log(level, message):
|
|
136
|
+
_post("/api/logs", {
|
|
137
|
+
"service": _config["service_name"],
|
|
138
|
+
"level": level,
|
|
139
|
+
"message": message,
|
|
140
|
+
"timestamp": _now_iso(),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def send_trace(route, method, duration_ms, status, trace_id=None, span_id=None, parent_span_id=""):
|
|
145
|
+
"""Send exactly ONE trace per request. (Bug fix: was sending two.)"""
|
|
146
|
+
_post("/api/traces", {
|
|
147
|
+
"service": _config["service_name"],
|
|
148
|
+
"traceId": trace_id or _gen_id(),
|
|
149
|
+
"spanId": span_id or _gen_id(),
|
|
150
|
+
"parentSpanId": parent_span_id or "",
|
|
151
|
+
"route": route,
|
|
152
|
+
"method": method,
|
|
153
|
+
"duration": duration_ms,
|
|
154
|
+
"status": status,
|
|
155
|
+
"timestamp": _now_iso(),
|
|
156
|
+
"type": "trace",
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---- per-request recording ----
|
|
161
|
+
def _record(route, method, duration_ms, status, trace_id=None, span_id=None, parent_span_id=""):
|
|
162
|
+
with _lock:
|
|
163
|
+
_stats["requests"] += 1
|
|
164
|
+
_stats["total_duration"] += duration_ms
|
|
165
|
+
if status >= 400:
|
|
166
|
+
_stats["errors"] += 1
|
|
167
|
+
|
|
168
|
+
if _config["debug"]:
|
|
169
|
+
_log(f"{method} {route} -> {status} ({duration_ms:.0f}ms)")
|
|
170
|
+
|
|
171
|
+
send_trace(route, method, duration_ms, status, trace_id, span_id, parent_span_id)
|
|
172
|
+
send_metric("response_time", round(duration_ms, 2), "ms")
|
|
173
|
+
|
|
174
|
+
if status >= 500:
|
|
175
|
+
send_log("ERROR", f"{method} {route} returned {status} in {duration_ms:.0f}ms")
|
|
176
|
+
elif status in (401, 403):
|
|
177
|
+
send_log("WARN", f"Auth failed: {method} {route} returned {status} in {duration_ms:.0f}ms")
|
|
178
|
+
elif status == 429:
|
|
179
|
+
send_log("WARN", f"Rate limited: {method} {route} returned 429 in {duration_ms:.0f}ms")
|
|
180
|
+
elif status >= 400:
|
|
181
|
+
send_log("WARN", f"{method} {route} returned {status} in {duration_ms:.0f}ms")
|
|
182
|
+
# Two-tier slow-request logging — a 1.2s request and an 8s request are
|
|
183
|
+
# very different severities of problem, so don't bucket them the same.
|
|
184
|
+
if duration_ms > 3000:
|
|
185
|
+
send_log("ERROR", f"Very slow request: {method} {route} took {duration_ms:.0f}ms")
|
|
186
|
+
elif duration_ms > 1000:
|
|
187
|
+
send_log("WARN", f"Slow request: {method} {route} took {duration_ms:.0f}ms")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---- 10s rollup reporter ----
|
|
191
|
+
ROLLUP_INTERVAL_SECS = 10
|
|
192
|
+
|
|
193
|
+
def _reporter():
|
|
194
|
+
while True:
|
|
195
|
+
time.sleep(ROLLUP_INTERVAL_SECS)
|
|
196
|
+
with _lock:
|
|
197
|
+
# Snapshot then RESET — same fix as the Node SDK: a counter
|
|
198
|
+
# that never resets sends a useless lifetime total each cycle
|
|
199
|
+
# instead of "what happened in the last 10 seconds".
|
|
200
|
+
reqs = _stats["requests"]
|
|
201
|
+
errs = _stats["errors"]
|
|
202
|
+
total = _stats["total_duration"]
|
|
203
|
+
_stats["requests"] = 0
|
|
204
|
+
_stats["errors"] = 0
|
|
205
|
+
_stats["total_duration"] = 0.0
|
|
206
|
+
|
|
207
|
+
avg = (total / reqs) if reqs else 0.0
|
|
208
|
+
err_rate = (errs / reqs * 100) if reqs else 0.0
|
|
209
|
+
req_per_sec = reqs / ROLLUP_INTERVAL_SECS
|
|
210
|
+
|
|
211
|
+
if _HAS_PSUTIL:
|
|
212
|
+
mem_mb = psutil.Process().memory_info().rss / 1024 / 1024
|
|
213
|
+
else:
|
|
214
|
+
import resource
|
|
215
|
+
mem_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
|
|
216
|
+
|
|
217
|
+
send_metric("app_memory_mb", round(mem_mb, 2), "MB")
|
|
218
|
+
send_metric("app_avg_response_time", round(avg, 2), "ms")
|
|
219
|
+
send_metric("http_request_count", reqs, "count")
|
|
220
|
+
send_metric("http_requests_per_second", round(req_per_sec, 2), "req/s")
|
|
221
|
+
send_metric("app_error_rate", round(err_rate, 2), "%")
|
|
222
|
+
|
|
223
|
+
if _config["debug"]:
|
|
224
|
+
_log(f"rollup: mem={mem_mb:.1f}MB avg={avg:.0f}ms reqs={reqs} ({req_per_sec:.1f}/s) errors={errs}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ---- framework integrations ----
|
|
228
|
+
def _instrument_flask(app):
|
|
229
|
+
from flask import request, g
|
|
230
|
+
|
|
231
|
+
@app.before_request
|
|
232
|
+
def _before():
|
|
233
|
+
g._nexus_start = time.time()
|
|
234
|
+
# A trace ID on the incoming request means an upstream instrumented
|
|
235
|
+
# service is already mid-trace — continue it instead of starting a
|
|
236
|
+
# new one. No header means this request is the root of a new trace.
|
|
237
|
+
incoming_trace_id = request.headers.get(TRACE_ID_HEADER)
|
|
238
|
+
incoming_span_id = request.headers.get(SPAN_ID_HEADER)
|
|
239
|
+
g._nexus_trace_id = incoming_trace_id or _gen_id()
|
|
240
|
+
g._nexus_span_id = _gen_id()
|
|
241
|
+
g._nexus_parent_span_id = incoming_span_id or ""
|
|
242
|
+
g._nexus_ctx_token = _trace_ctx.set((g._nexus_trace_id, g._nexus_span_id))
|
|
243
|
+
|
|
244
|
+
@app.after_request
|
|
245
|
+
def _after(response):
|
|
246
|
+
start = getattr(g, "_nexus_start", None)
|
|
247
|
+
if start is not None:
|
|
248
|
+
duration_ms = (time.time() - start) * 1000.0
|
|
249
|
+
_record(
|
|
250
|
+
request.path, request.method, duration_ms, response.status_code,
|
|
251
|
+
trace_id=getattr(g, "_nexus_trace_id", None),
|
|
252
|
+
span_id=getattr(g, "_nexus_span_id", None),
|
|
253
|
+
parent_span_id=getattr(g, "_nexus_parent_span_id", ""),
|
|
254
|
+
)
|
|
255
|
+
token = getattr(g, "_nexus_ctx_token", None)
|
|
256
|
+
if token is not None:
|
|
257
|
+
_trace_ctx.reset(token)
|
|
258
|
+
return response
|
|
259
|
+
|
|
260
|
+
_log("Flask instrumentation attached")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _instrument_fastapi(app):
|
|
264
|
+
from starlette.requests import Request
|
|
265
|
+
|
|
266
|
+
@app.middleware("http")
|
|
267
|
+
async def _mw(request: Request, call_next):
|
|
268
|
+
start = time.time()
|
|
269
|
+
incoming_trace_id = request.headers.get(TRACE_ID_HEADER)
|
|
270
|
+
incoming_span_id = request.headers.get(SPAN_ID_HEADER)
|
|
271
|
+
trace_id = incoming_trace_id or _gen_id()
|
|
272
|
+
span_id = _gen_id()
|
|
273
|
+
token = _trace_ctx.set((trace_id, span_id))
|
|
274
|
+
try:
|
|
275
|
+
response = await call_next(request)
|
|
276
|
+
finally:
|
|
277
|
+
_trace_ctx.reset(token)
|
|
278
|
+
duration_ms = (time.time() - start) * 1000.0
|
|
279
|
+
_record(
|
|
280
|
+
request.url.path, request.method, duration_ms, response.status_code,
|
|
281
|
+
trace_id=trace_id, span_id=span_id, parent_span_id=incoming_span_id or "",
|
|
282
|
+
)
|
|
283
|
+
return response
|
|
284
|
+
|
|
285
|
+
_log("FastAPI instrumentation attached")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# Propagate the current request's trace context onto any outgoing `requests`
|
|
289
|
+
# call the instrumented app makes (e.g. calling another instrumented
|
|
290
|
+
# service) — this is what actually makes tracing "distributed" rather than
|
|
291
|
+
# each service recording its own disconnected spans. Patches only the one
|
|
292
|
+
# low-level method every requests.* helper (get/post/put/...) funnels
|
|
293
|
+
# through, and only adds headers — it never sends anything itself, so it
|
|
294
|
+
# carries none of the "recorded a trace for every outgoing call" risk that
|
|
295
|
+
# the Node SDK's removed http.request patch had.
|
|
296
|
+
_original_request = requests.Session.request
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _traced_request(self, method, url, *args, **kwargs):
|
|
300
|
+
if not (_config["backend_url"] and str(url).startswith(_config["backend_url"])):
|
|
301
|
+
ctx = _trace_ctx.get()
|
|
302
|
+
if ctx is not None:
|
|
303
|
+
headers = kwargs.get("headers") or {}
|
|
304
|
+
headers = dict(headers)
|
|
305
|
+
headers[TRACE_ID_HEADER] = ctx[0]
|
|
306
|
+
headers[SPAN_ID_HEADER] = ctx[1]
|
|
307
|
+
kwargs["headers"] = headers
|
|
308
|
+
return _original_request(self, method, url, *args, **kwargs)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
requests.Session.request = _traced_request
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# ---- public init ----
|
|
315
|
+
def init(app=None, service_name=None, backend_url=None, api_key=None, framework="flask", debug=False):
|
|
316
|
+
if service_name:
|
|
317
|
+
_config["service_name"] = service_name
|
|
318
|
+
if backend_url:
|
|
319
|
+
_config["backend_url"] = backend_url
|
|
320
|
+
_config["api_key"] = api_key or os.getenv("NEXUSAPM_API_KEY", "")
|
|
321
|
+
_config["debug"] = debug
|
|
322
|
+
|
|
323
|
+
_log(f"initializing for service: {_config['service_name']}")
|
|
324
|
+
if not _config["api_key"] and debug:
|
|
325
|
+
_log("WARNING: no api_key provided — /api/metrics, /api/logs, /api/traces all require one and will return 401")
|
|
326
|
+
|
|
327
|
+
if app is not None:
|
|
328
|
+
if framework == "flask":
|
|
329
|
+
_instrument_flask(app)
|
|
330
|
+
elif framework == "fastapi":
|
|
331
|
+
_instrument_fastapi(app)
|
|
332
|
+
else:
|
|
333
|
+
_log(f"unknown framework '{framework}', no auto-instrumentation")
|
|
334
|
+
|
|
335
|
+
t = threading.Thread(target=_reporter, daemon=True)
|
|
336
|
+
t.start()
|
|
337
|
+
|
|
338
|
+
_announce_identity()
|
|
339
|
+
threading.Thread(target=_identity_reporter, daemon=True).start()
|
|
340
|
+
|
|
341
|
+
send_log("INFO", f"NexusAPM agent started — monitoring {_config['service_name']}")
|
|
342
|
+
atexit.register(
|
|
343
|
+
lambda: send_log("INFO", f"NexusAPM agent stopped — {_config['service_name']}")
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
_log("ready! Monitoring all HTTP requests automatically.")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nexusapm
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: NexusAPM Python auto-instrumentation SDK (Flask / FastAPI)
|
|
5
|
+
Home-page: https://github.com/Intelli-APM/apm/tree/main/agent/nexusapm-python
|
|
6
|
+
Author: NexusAPM
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Framework :: Flask
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Requires-Python: >=3.8
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests
|
|
19
|
+
Provides-Extra: accurate-memory
|
|
20
|
+
Requires-Dist: psutil; extra == "accurate-memory"
|
|
21
|
+
Dynamic: author
|
|
22
|
+
Dynamic: classifier
|
|
23
|
+
Dynamic: description
|
|
24
|
+
Dynamic: description-content-type
|
|
25
|
+
Dynamic: home-page
|
|
26
|
+
Dynamic: license
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
Dynamic: provides-extra
|
|
29
|
+
Dynamic: requires-dist
|
|
30
|
+
Dynamic: requires-python
|
|
31
|
+
Dynamic: summary
|
|
32
|
+
|
|
33
|
+
# nexusapm (Python SDK)
|
|
34
|
+
|
|
35
|
+
One-line auto-instrumentation for Flask and FastAPI applications, reporting
|
|
36
|
+
to a [NexusAPM](https://github.com/Intelli-APM/apm) backend.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install nexusapm
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
**Flask:**
|
|
47
|
+
```python
|
|
48
|
+
import nexusapm
|
|
49
|
+
|
|
50
|
+
nexusapm.init(app, service_name="my-app",
|
|
51
|
+
backend_url="https://your-nexusapm-backend.example.com",
|
|
52
|
+
api_key="your-api-key") # or set NEXUSAPM_API_KEY in the environment
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
**FastAPI:**
|
|
56
|
+
```python
|
|
57
|
+
import nexusapm
|
|
58
|
+
|
|
59
|
+
nexusapm.init(app, service_name="my-api", framework="fastapi",
|
|
60
|
+
backend_url="https://your-nexusapm-backend.example.com",
|
|
61
|
+
api_key="your-api-key")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
That's it — every request handled after this point is automatically traced.
|
|
65
|
+
|
|
66
|
+
## What gets captured automatically
|
|
67
|
+
|
|
68
|
+
- **Per request:** route, method, status, duration → sent as a trace
|
|
69
|
+
- **Per request:** response time → sent as a metric
|
|
70
|
+
- **Errors and slow requests** (4xx/5xx, or over 1s) → sent as log lines
|
|
71
|
+
- **Every 10 seconds:** app-level rollups — memory usage, average response
|
|
72
|
+
time, request throughput, error rate
|
|
73
|
+
|
|
74
|
+
## Distributed tracing across services
|
|
75
|
+
|
|
76
|
+
If this app calls another NexusAPM-instrumented service over HTTP (via the
|
|
77
|
+
`requests` library), the current request's trace ID is automatically
|
|
78
|
+
propagated onto that outgoing call — no extra code needed. The two
|
|
79
|
+
services' spans show up correlated under one trace in the NexusAPM
|
|
80
|
+
dashboard, instead of two disconnected traces.
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
| Option | Required | Default | Notes |
|
|
85
|
+
|----------------|----------|----------------------------------|--------------------------------------------|
|
|
86
|
+
| `service_name` | No | `"unknown-python-service"` | Shows up as this service's name everywhere |
|
|
87
|
+
| `backend_url` | No | `http://localhost:3001` | Your NexusAPM backend's URL |
|
|
88
|
+
| `api_key` | No* | `NEXUSAPM_API_KEY` env var | *Required for any data to actually be accepted — falls back to the env var if not passed explicitly |
|
|
89
|
+
| `framework` | No | `"flask"` | Set to `"fastapi"` for FastAPI apps |
|
|
90
|
+
| `debug` | No | `False` | Logs `[NexusAPM]`-style debug lines |
|
|
91
|
+
|
|
92
|
+
## Optional: more accurate memory reporting
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install nexusapm[accurate-memory]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Uses `psutil` for real RSS memory instead of the standard-library fallback.
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nexusapm
|
nexusapm-1.0.0/setup.cfg
ADDED
nexusapm-1.0.0/setup.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from setuptools import setup, find_packages
|
|
3
|
+
|
|
4
|
+
long_description = (Path(__file__).parent / "README.md").read_text(encoding="utf-8")
|
|
5
|
+
|
|
6
|
+
setup(
|
|
7
|
+
name="nexusapm",
|
|
8
|
+
version="1.0.0",
|
|
9
|
+
description="NexusAPM Python auto-instrumentation SDK (Flask / FastAPI)",
|
|
10
|
+
long_description=long_description,
|
|
11
|
+
long_description_content_type="text/markdown",
|
|
12
|
+
author="NexusAPM",
|
|
13
|
+
license="MIT",
|
|
14
|
+
url="https://github.com/Intelli-APM/apm/tree/main/agent/nexusapm-python",
|
|
15
|
+
packages=find_packages(),
|
|
16
|
+
install_requires=["requests"],
|
|
17
|
+
extras_require={"accurate-memory": ["psutil"]},
|
|
18
|
+
python_requires=">=3.8",
|
|
19
|
+
classifiers=[
|
|
20
|
+
"Development Status :: 4 - Beta",
|
|
21
|
+
"Intended Audience :: Developers",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Programming Language :: Python :: 3",
|
|
24
|
+
"Framework :: Flask",
|
|
25
|
+
"Framework :: FastAPI",
|
|
26
|
+
"Topic :: System :: Monitoring",
|
|
27
|
+
],
|
|
28
|
+
)
|