impulse-telemetry 0.1.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.
- impulse_telemetry-0.1.0/PKG-INFO +203 -0
- impulse_telemetry-0.1.0/README.md +173 -0
- impulse_telemetry-0.1.0/impulse_telemetry.egg-info/PKG-INFO +203 -0
- impulse_telemetry-0.1.0/impulse_telemetry.egg-info/SOURCES.txt +12 -0
- impulse_telemetry-0.1.0/impulse_telemetry.egg-info/dependency_links.txt +1 -0
- impulse_telemetry-0.1.0/impulse_telemetry.egg-info/requires.txt +16 -0
- impulse_telemetry-0.1.0/impulse_telemetry.egg-info/top_level.txt +1 -0
- impulse_telemetry-0.1.0/pyproject.toml +50 -0
- impulse_telemetry-0.1.0/setup.cfg +4 -0
- impulse_telemetry-0.1.0/tests/test_logging.py +57 -0
- impulse_telemetry-0.1.0/tests/test_metrics.py +68 -0
- impulse_telemetry-0.1.0/tests/test_middleware.py +52 -0
- impulse_telemetry-0.1.0/tests/test_ml.py +94 -0
- impulse_telemetry-0.1.0/tests/test_tracing.py +66 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: impulse-telemetry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Observability SDK for Impulse microservices
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: observability,telemetry,opentelemetry,prometheus,fastapi,ml
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: System :: Monitoring
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: opentelemetry-sdk>=1.24
|
|
17
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.24
|
|
18
|
+
Requires-Dist: opentelemetry-propagator-b3>=1.24
|
|
19
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.45b0
|
|
20
|
+
Requires-Dist: opentelemetry-instrumentation-requests>=0.45b0
|
|
21
|
+
Requires-Dist: opentelemetry-instrumentation-httpx>=0.45b0
|
|
22
|
+
Requires-Dist: prometheus-client>=0.20
|
|
23
|
+
Requires-Dist: structlog>=24.0
|
|
24
|
+
Provides-Extra: extras
|
|
25
|
+
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.45b0; extra == "extras"
|
|
26
|
+
Requires-Dist: opentelemetry-instrumentation-redis>=0.45b0; extra == "extras"
|
|
27
|
+
Requires-Dist: opentelemetry-instrumentation-celery>=0.45b0; extra == "extras"
|
|
28
|
+
Provides-Extra: ml
|
|
29
|
+
Requires-Dist: pandas>=2.0; extra == "ml"
|
|
30
|
+
|
|
31
|
+
# impulse_telemetry
|
|
32
|
+
|
|
33
|
+
Observability SDK for Impulse microservices. One `init()` call wires up distributed tracing, Prometheus metrics, structured logging, and FastAPI middleware.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install impulse-telemetry
|
|
39
|
+
|
|
40
|
+
# With SQLAlchemy / Redis / Celery auto-instrumentation
|
|
41
|
+
pip install "impulse-telemetry[extras]"
|
|
42
|
+
|
|
43
|
+
# With ML data quality checks (pandas required)
|
|
44
|
+
pip install "impulse-telemetry[ml]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import FastAPI
|
|
51
|
+
from impulse_telemetry import init
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
init(app, service="my-service", version="1.0.0", env="production")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Every HTTP request now emits RED metrics, a distributed trace, and a structured JSON log line — automatically.
|
|
58
|
+
|
|
59
|
+
## `init()` parameters
|
|
60
|
+
|
|
61
|
+
| Parameter | Default | Description |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `app` | `None` | FastAPI app. Pass to enable HTTP middleware. |
|
|
64
|
+
| `service` | **required** | Service name — appears on all telemetry. |
|
|
65
|
+
| `version` | `"0.0.0"` | Semver string tagged on all telemetry. |
|
|
66
|
+
| `env` | `$ENV` | `"production"` \| `"staging"` \| `"dev"` |
|
|
67
|
+
| `otlp_endpoint` | `$OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP Collector gRPC address. |
|
|
68
|
+
| `prometheus_port` | `None` | Expose `/metrics` on this port (for workers without HTTP). |
|
|
69
|
+
| `log_level` | `"INFO"` | `"DEBUG"` \| `"INFO"` \| `"WARNING"` \| `"ERROR"` |
|
|
70
|
+
| `enable_ml` | `False` | Register ML-specific metric instruments. |
|
|
71
|
+
|
|
72
|
+
## Logging
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from impulse_telemetry.logging import get_logger
|
|
76
|
+
|
|
77
|
+
log = get_logger(__name__)
|
|
78
|
+
log.info("prediction_served", model_id="rec-v3", latency_ms=43)
|
|
79
|
+
# {"service":"my-service","trace_id":"abc..","event":"prediction_served","model_id":"rec-v3",...}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`trace_id` and `span_id` are injected automatically from the active OTEL span.
|
|
83
|
+
|
|
84
|
+
### Per-request context
|
|
85
|
+
|
|
86
|
+
Bind fields once per request — they appear on every subsequent log line for that request.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from impulse_telemetry.logging import bind_request_context, clear_request_context
|
|
90
|
+
|
|
91
|
+
token = bind_request_context(user_id="usr_123", request_id="req_456")
|
|
92
|
+
log.info("doing_work") # user_id + request_id injected automatically
|
|
93
|
+
clear_request_context(token)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Metrics
|
|
97
|
+
|
|
98
|
+
Prometheus metrics are pre-registered and labeled with `service` and `env`.
|
|
99
|
+
|
|
100
|
+
### Automatic (via middleware)
|
|
101
|
+
|
|
102
|
+
| Metric | Type | Description |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `http_requests_total` | Counter | Requests by route, method, status |
|
|
105
|
+
| `http_request_errors_total` | Counter | 4xx / 5xx responses |
|
|
106
|
+
| `http_request_duration_seconds` | Histogram | Request latency |
|
|
107
|
+
| `http_active_requests` | Gauge | In-flight requests |
|
|
108
|
+
|
|
109
|
+
### Manual
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from impulse_telemetry.metrics import get_metrics
|
|
113
|
+
|
|
114
|
+
m = get_metrics()
|
|
115
|
+
m.dependency_latency.labels(**m.labels(dependency="redis")).observe(elapsed)
|
|
116
|
+
m.dependency_errors.labels(**m.labels(dependency="redis")).inc()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Available instruments: `rate_limiter_hits`, `rate_limiter_remaining`, `dependency_latency`, `dependency_errors`, `queue_depth`, `circuit_breaker`.
|
|
120
|
+
|
|
121
|
+
## Tracing
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from impulse_telemetry.tracing import get_tracer, inject_headers
|
|
125
|
+
|
|
126
|
+
tracer = get_tracer(__name__)
|
|
127
|
+
|
|
128
|
+
with tracer.start_as_current_span("my_operation") as span:
|
|
129
|
+
span.set_attribute("key", "value")
|
|
130
|
+
headers = inject_headers({}) # propagate W3C traceparent to downstream
|
|
131
|
+
requests.get("http://other-service/api", headers=headers)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Auto-instrumented libraries (when installed): `requests`, `httpx`, `SQLAlchemy`, `Redis`, `Celery`.
|
|
135
|
+
|
|
136
|
+
## ML Monitoring
|
|
137
|
+
|
|
138
|
+
Enable at startup with `enable_ml=True`.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from impulse_telemetry.ml import MLMonitor
|
|
142
|
+
|
|
143
|
+
monitor = MLMonitor(model_id="rec-v3", user_id=user_id)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Inference tracing
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# Context manager
|
|
150
|
+
with monitor.inference(features=df) as span:
|
|
151
|
+
result = model.predict(df)
|
|
152
|
+
span.record_output(result)
|
|
153
|
+
|
|
154
|
+
# Decorator
|
|
155
|
+
@monitor.trace
|
|
156
|
+
def predict(features):
|
|
157
|
+
return model.predict(features)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Both record inference latency, error count, and run data quality checks (missing values, schema violations) automatically.
|
|
161
|
+
|
|
162
|
+
### Drift & performance metrics
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# Call from batch evaluation jobs
|
|
166
|
+
monitor.record_drift("age", score=0.18, metric="psi")
|
|
167
|
+
monitor.record_performance(rmse=0.042, precision=0.87, recall=0.81)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### ML metric instruments
|
|
171
|
+
|
|
172
|
+
| Metric | Type | Description |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `ml_inference_duration_seconds` | Histogram | Per-model inference latency |
|
|
175
|
+
| `ml_inference_requests_total` | Counter | Inference count |
|
|
176
|
+
| `ml_inference_errors_total` | Counter | Inference errors |
|
|
177
|
+
| `ml_missing_feature_rate` | Gauge | Missing values per column |
|
|
178
|
+
| `ml_schema_violations_total` | Counter | Schema violation count |
|
|
179
|
+
| `ml_feature_drift` | Gauge | Feature drift score |
|
|
180
|
+
| `ml_prediction_drift` | Gauge | Prediction drift score |
|
|
181
|
+
| `ml_rmse` | Gauge | Rolling RMSE |
|
|
182
|
+
| `ml_precision` | Gauge | Rolling precision |
|
|
183
|
+
| `ml_recall` | Gauge | Rolling recall |
|
|
184
|
+
|
|
185
|
+
## Background workers
|
|
186
|
+
|
|
187
|
+
For services without an HTTP server, expose metrics on a dedicated port:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
init(service="ingest-worker", prometheus_port=9090)
|
|
191
|
+
# Prometheus scrapes localhost:9090/metrics
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Environment variables
|
|
195
|
+
|
|
196
|
+
| Variable | Description |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP Collector gRPC address |
|
|
199
|
+
| `ENV` | Deployment environment (`production`, `staging`, `dev`) |
|
|
200
|
+
|
|
201
|
+
## See also
|
|
202
|
+
|
|
203
|
+
[examples.py](examples.py) — minimal, runnable code for every feature.
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# impulse_telemetry
|
|
2
|
+
|
|
3
|
+
Observability SDK for Impulse microservices. One `init()` call wires up distributed tracing, Prometheus metrics, structured logging, and FastAPI middleware.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install impulse-telemetry
|
|
9
|
+
|
|
10
|
+
# With SQLAlchemy / Redis / Celery auto-instrumentation
|
|
11
|
+
pip install "impulse-telemetry[extras]"
|
|
12
|
+
|
|
13
|
+
# With ML data quality checks (pandas required)
|
|
14
|
+
pip install "impulse-telemetry[ml]"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Quickstart
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from fastapi import FastAPI
|
|
21
|
+
from impulse_telemetry import init
|
|
22
|
+
|
|
23
|
+
app = FastAPI()
|
|
24
|
+
init(app, service="my-service", version="1.0.0", env="production")
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Every HTTP request now emits RED metrics, a distributed trace, and a structured JSON log line — automatically.
|
|
28
|
+
|
|
29
|
+
## `init()` parameters
|
|
30
|
+
|
|
31
|
+
| Parameter | Default | Description |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| `app` | `None` | FastAPI app. Pass to enable HTTP middleware. |
|
|
34
|
+
| `service` | **required** | Service name — appears on all telemetry. |
|
|
35
|
+
| `version` | `"0.0.0"` | Semver string tagged on all telemetry. |
|
|
36
|
+
| `env` | `$ENV` | `"production"` \| `"staging"` \| `"dev"` |
|
|
37
|
+
| `otlp_endpoint` | `$OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP Collector gRPC address. |
|
|
38
|
+
| `prometheus_port` | `None` | Expose `/metrics` on this port (for workers without HTTP). |
|
|
39
|
+
| `log_level` | `"INFO"` | `"DEBUG"` \| `"INFO"` \| `"WARNING"` \| `"ERROR"` |
|
|
40
|
+
| `enable_ml` | `False` | Register ML-specific metric instruments. |
|
|
41
|
+
|
|
42
|
+
## Logging
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from impulse_telemetry.logging import get_logger
|
|
46
|
+
|
|
47
|
+
log = get_logger(__name__)
|
|
48
|
+
log.info("prediction_served", model_id="rec-v3", latency_ms=43)
|
|
49
|
+
# {"service":"my-service","trace_id":"abc..","event":"prediction_served","model_id":"rec-v3",...}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`trace_id` and `span_id` are injected automatically from the active OTEL span.
|
|
53
|
+
|
|
54
|
+
### Per-request context
|
|
55
|
+
|
|
56
|
+
Bind fields once per request — they appear on every subsequent log line for that request.
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from impulse_telemetry.logging import bind_request_context, clear_request_context
|
|
60
|
+
|
|
61
|
+
token = bind_request_context(user_id="usr_123", request_id="req_456")
|
|
62
|
+
log.info("doing_work") # user_id + request_id injected automatically
|
|
63
|
+
clear_request_context(token)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Metrics
|
|
67
|
+
|
|
68
|
+
Prometheus metrics are pre-registered and labeled with `service` and `env`.
|
|
69
|
+
|
|
70
|
+
### Automatic (via middleware)
|
|
71
|
+
|
|
72
|
+
| Metric | Type | Description |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `http_requests_total` | Counter | Requests by route, method, status |
|
|
75
|
+
| `http_request_errors_total` | Counter | 4xx / 5xx responses |
|
|
76
|
+
| `http_request_duration_seconds` | Histogram | Request latency |
|
|
77
|
+
| `http_active_requests` | Gauge | In-flight requests |
|
|
78
|
+
|
|
79
|
+
### Manual
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from impulse_telemetry.metrics import get_metrics
|
|
83
|
+
|
|
84
|
+
m = get_metrics()
|
|
85
|
+
m.dependency_latency.labels(**m.labels(dependency="redis")).observe(elapsed)
|
|
86
|
+
m.dependency_errors.labels(**m.labels(dependency="redis")).inc()
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Available instruments: `rate_limiter_hits`, `rate_limiter_remaining`, `dependency_latency`, `dependency_errors`, `queue_depth`, `circuit_breaker`.
|
|
90
|
+
|
|
91
|
+
## Tracing
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from impulse_telemetry.tracing import get_tracer, inject_headers
|
|
95
|
+
|
|
96
|
+
tracer = get_tracer(__name__)
|
|
97
|
+
|
|
98
|
+
with tracer.start_as_current_span("my_operation") as span:
|
|
99
|
+
span.set_attribute("key", "value")
|
|
100
|
+
headers = inject_headers({}) # propagate W3C traceparent to downstream
|
|
101
|
+
requests.get("http://other-service/api", headers=headers)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Auto-instrumented libraries (when installed): `requests`, `httpx`, `SQLAlchemy`, `Redis`, `Celery`.
|
|
105
|
+
|
|
106
|
+
## ML Monitoring
|
|
107
|
+
|
|
108
|
+
Enable at startup with `enable_ml=True`.
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from impulse_telemetry.ml import MLMonitor
|
|
112
|
+
|
|
113
|
+
monitor = MLMonitor(model_id="rec-v3", user_id=user_id)
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Inference tracing
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
# Context manager
|
|
120
|
+
with monitor.inference(features=df) as span:
|
|
121
|
+
result = model.predict(df)
|
|
122
|
+
span.record_output(result)
|
|
123
|
+
|
|
124
|
+
# Decorator
|
|
125
|
+
@monitor.trace
|
|
126
|
+
def predict(features):
|
|
127
|
+
return model.predict(features)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Both record inference latency, error count, and run data quality checks (missing values, schema violations) automatically.
|
|
131
|
+
|
|
132
|
+
### Drift & performance metrics
|
|
133
|
+
|
|
134
|
+
```python
|
|
135
|
+
# Call from batch evaluation jobs
|
|
136
|
+
monitor.record_drift("age", score=0.18, metric="psi")
|
|
137
|
+
monitor.record_performance(rmse=0.042, precision=0.87, recall=0.81)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### ML metric instruments
|
|
141
|
+
|
|
142
|
+
| Metric | Type | Description |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `ml_inference_duration_seconds` | Histogram | Per-model inference latency |
|
|
145
|
+
| `ml_inference_requests_total` | Counter | Inference count |
|
|
146
|
+
| `ml_inference_errors_total` | Counter | Inference errors |
|
|
147
|
+
| `ml_missing_feature_rate` | Gauge | Missing values per column |
|
|
148
|
+
| `ml_schema_violations_total` | Counter | Schema violation count |
|
|
149
|
+
| `ml_feature_drift` | Gauge | Feature drift score |
|
|
150
|
+
| `ml_prediction_drift` | Gauge | Prediction drift score |
|
|
151
|
+
| `ml_rmse` | Gauge | Rolling RMSE |
|
|
152
|
+
| `ml_precision` | Gauge | Rolling precision |
|
|
153
|
+
| `ml_recall` | Gauge | Rolling recall |
|
|
154
|
+
|
|
155
|
+
## Background workers
|
|
156
|
+
|
|
157
|
+
For services without an HTTP server, expose metrics on a dedicated port:
|
|
158
|
+
|
|
159
|
+
```python
|
|
160
|
+
init(service="ingest-worker", prometheus_port=9090)
|
|
161
|
+
# Prometheus scrapes localhost:9090/metrics
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Environment variables
|
|
165
|
+
|
|
166
|
+
| Variable | Description |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP Collector gRPC address |
|
|
169
|
+
| `ENV` | Deployment environment (`production`, `staging`, `dev`) |
|
|
170
|
+
|
|
171
|
+
## See also
|
|
172
|
+
|
|
173
|
+
[examples.py](examples.py) — minimal, runnable code for every feature.
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: impulse-telemetry
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Observability SDK for Impulse microservices
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: observability,telemetry,opentelemetry,prometheus,fastapi,ml
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: System :: Monitoring
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: opentelemetry-sdk>=1.24
|
|
17
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.24
|
|
18
|
+
Requires-Dist: opentelemetry-propagator-b3>=1.24
|
|
19
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.45b0
|
|
20
|
+
Requires-Dist: opentelemetry-instrumentation-requests>=0.45b0
|
|
21
|
+
Requires-Dist: opentelemetry-instrumentation-httpx>=0.45b0
|
|
22
|
+
Requires-Dist: prometheus-client>=0.20
|
|
23
|
+
Requires-Dist: structlog>=24.0
|
|
24
|
+
Provides-Extra: extras
|
|
25
|
+
Requires-Dist: opentelemetry-instrumentation-sqlalchemy>=0.45b0; extra == "extras"
|
|
26
|
+
Requires-Dist: opentelemetry-instrumentation-redis>=0.45b0; extra == "extras"
|
|
27
|
+
Requires-Dist: opentelemetry-instrumentation-celery>=0.45b0; extra == "extras"
|
|
28
|
+
Provides-Extra: ml
|
|
29
|
+
Requires-Dist: pandas>=2.0; extra == "ml"
|
|
30
|
+
|
|
31
|
+
# impulse_telemetry
|
|
32
|
+
|
|
33
|
+
Observability SDK for Impulse microservices. One `init()` call wires up distributed tracing, Prometheus metrics, structured logging, and FastAPI middleware.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install impulse-telemetry
|
|
39
|
+
|
|
40
|
+
# With SQLAlchemy / Redis / Celery auto-instrumentation
|
|
41
|
+
pip install "impulse-telemetry[extras]"
|
|
42
|
+
|
|
43
|
+
# With ML data quality checks (pandas required)
|
|
44
|
+
pip install "impulse-telemetry[ml]"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Quickstart
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from fastapi import FastAPI
|
|
51
|
+
from impulse_telemetry import init
|
|
52
|
+
|
|
53
|
+
app = FastAPI()
|
|
54
|
+
init(app, service="my-service", version="1.0.0", env="production")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Every HTTP request now emits RED metrics, a distributed trace, and a structured JSON log line — automatically.
|
|
58
|
+
|
|
59
|
+
## `init()` parameters
|
|
60
|
+
|
|
61
|
+
| Parameter | Default | Description |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| `app` | `None` | FastAPI app. Pass to enable HTTP middleware. |
|
|
64
|
+
| `service` | **required** | Service name — appears on all telemetry. |
|
|
65
|
+
| `version` | `"0.0.0"` | Semver string tagged on all telemetry. |
|
|
66
|
+
| `env` | `$ENV` | `"production"` \| `"staging"` \| `"dev"` |
|
|
67
|
+
| `otlp_endpoint` | `$OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP Collector gRPC address. |
|
|
68
|
+
| `prometheus_port` | `None` | Expose `/metrics` on this port (for workers without HTTP). |
|
|
69
|
+
| `log_level` | `"INFO"` | `"DEBUG"` \| `"INFO"` \| `"WARNING"` \| `"ERROR"` |
|
|
70
|
+
| `enable_ml` | `False` | Register ML-specific metric instruments. |
|
|
71
|
+
|
|
72
|
+
## Logging
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from impulse_telemetry.logging import get_logger
|
|
76
|
+
|
|
77
|
+
log = get_logger(__name__)
|
|
78
|
+
log.info("prediction_served", model_id="rec-v3", latency_ms=43)
|
|
79
|
+
# {"service":"my-service","trace_id":"abc..","event":"prediction_served","model_id":"rec-v3",...}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`trace_id` and `span_id` are injected automatically from the active OTEL span.
|
|
83
|
+
|
|
84
|
+
### Per-request context
|
|
85
|
+
|
|
86
|
+
Bind fields once per request — they appear on every subsequent log line for that request.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from impulse_telemetry.logging import bind_request_context, clear_request_context
|
|
90
|
+
|
|
91
|
+
token = bind_request_context(user_id="usr_123", request_id="req_456")
|
|
92
|
+
log.info("doing_work") # user_id + request_id injected automatically
|
|
93
|
+
clear_request_context(token)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Metrics
|
|
97
|
+
|
|
98
|
+
Prometheus metrics are pre-registered and labeled with `service` and `env`.
|
|
99
|
+
|
|
100
|
+
### Automatic (via middleware)
|
|
101
|
+
|
|
102
|
+
| Metric | Type | Description |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `http_requests_total` | Counter | Requests by route, method, status |
|
|
105
|
+
| `http_request_errors_total` | Counter | 4xx / 5xx responses |
|
|
106
|
+
| `http_request_duration_seconds` | Histogram | Request latency |
|
|
107
|
+
| `http_active_requests` | Gauge | In-flight requests |
|
|
108
|
+
|
|
109
|
+
### Manual
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from impulse_telemetry.metrics import get_metrics
|
|
113
|
+
|
|
114
|
+
m = get_metrics()
|
|
115
|
+
m.dependency_latency.labels(**m.labels(dependency="redis")).observe(elapsed)
|
|
116
|
+
m.dependency_errors.labels(**m.labels(dependency="redis")).inc()
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Available instruments: `rate_limiter_hits`, `rate_limiter_remaining`, `dependency_latency`, `dependency_errors`, `queue_depth`, `circuit_breaker`.
|
|
120
|
+
|
|
121
|
+
## Tracing
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from impulse_telemetry.tracing import get_tracer, inject_headers
|
|
125
|
+
|
|
126
|
+
tracer = get_tracer(__name__)
|
|
127
|
+
|
|
128
|
+
with tracer.start_as_current_span("my_operation") as span:
|
|
129
|
+
span.set_attribute("key", "value")
|
|
130
|
+
headers = inject_headers({}) # propagate W3C traceparent to downstream
|
|
131
|
+
requests.get("http://other-service/api", headers=headers)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Auto-instrumented libraries (when installed): `requests`, `httpx`, `SQLAlchemy`, `Redis`, `Celery`.
|
|
135
|
+
|
|
136
|
+
## ML Monitoring
|
|
137
|
+
|
|
138
|
+
Enable at startup with `enable_ml=True`.
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from impulse_telemetry.ml import MLMonitor
|
|
142
|
+
|
|
143
|
+
monitor = MLMonitor(model_id="rec-v3", user_id=user_id)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Inference tracing
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
# Context manager
|
|
150
|
+
with monitor.inference(features=df) as span:
|
|
151
|
+
result = model.predict(df)
|
|
152
|
+
span.record_output(result)
|
|
153
|
+
|
|
154
|
+
# Decorator
|
|
155
|
+
@monitor.trace
|
|
156
|
+
def predict(features):
|
|
157
|
+
return model.predict(features)
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Both record inference latency, error count, and run data quality checks (missing values, schema violations) automatically.
|
|
161
|
+
|
|
162
|
+
### Drift & performance metrics
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# Call from batch evaluation jobs
|
|
166
|
+
monitor.record_drift("age", score=0.18, metric="psi")
|
|
167
|
+
monitor.record_performance(rmse=0.042, precision=0.87, recall=0.81)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
### ML metric instruments
|
|
171
|
+
|
|
172
|
+
| Metric | Type | Description |
|
|
173
|
+
|---|---|---|
|
|
174
|
+
| `ml_inference_duration_seconds` | Histogram | Per-model inference latency |
|
|
175
|
+
| `ml_inference_requests_total` | Counter | Inference count |
|
|
176
|
+
| `ml_inference_errors_total` | Counter | Inference errors |
|
|
177
|
+
| `ml_missing_feature_rate` | Gauge | Missing values per column |
|
|
178
|
+
| `ml_schema_violations_total` | Counter | Schema violation count |
|
|
179
|
+
| `ml_feature_drift` | Gauge | Feature drift score |
|
|
180
|
+
| `ml_prediction_drift` | Gauge | Prediction drift score |
|
|
181
|
+
| `ml_rmse` | Gauge | Rolling RMSE |
|
|
182
|
+
| `ml_precision` | Gauge | Rolling precision |
|
|
183
|
+
| `ml_recall` | Gauge | Rolling recall |
|
|
184
|
+
|
|
185
|
+
## Background workers
|
|
186
|
+
|
|
187
|
+
For services without an HTTP server, expose metrics on a dedicated port:
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
init(service="ingest-worker", prometheus_port=9090)
|
|
191
|
+
# Prometheus scrapes localhost:9090/metrics
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Environment variables
|
|
195
|
+
|
|
196
|
+
| Variable | Description |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP Collector gRPC address |
|
|
199
|
+
| `ENV` | Deployment environment (`production`, `staging`, `dev`) |
|
|
200
|
+
|
|
201
|
+
## See also
|
|
202
|
+
|
|
203
|
+
[examples.py](examples.py) — minimal, runnable code for every feature.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
impulse_telemetry.egg-info/PKG-INFO
|
|
4
|
+
impulse_telemetry.egg-info/SOURCES.txt
|
|
5
|
+
impulse_telemetry.egg-info/dependency_links.txt
|
|
6
|
+
impulse_telemetry.egg-info/requires.txt
|
|
7
|
+
impulse_telemetry.egg-info/top_level.txt
|
|
8
|
+
tests/test_logging.py
|
|
9
|
+
tests/test_metrics.py
|
|
10
|
+
tests/test_middleware.py
|
|
11
|
+
tests/test_ml.py
|
|
12
|
+
tests/test_tracing.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
opentelemetry-sdk>=1.24
|
|
2
|
+
opentelemetry-exporter-otlp-proto-grpc>=1.24
|
|
3
|
+
opentelemetry-propagator-b3>=1.24
|
|
4
|
+
opentelemetry-instrumentation-fastapi>=0.45b0
|
|
5
|
+
opentelemetry-instrumentation-requests>=0.45b0
|
|
6
|
+
opentelemetry-instrumentation-httpx>=0.45b0
|
|
7
|
+
prometheus-client>=0.20
|
|
8
|
+
structlog>=24.0
|
|
9
|
+
|
|
10
|
+
[extras]
|
|
11
|
+
opentelemetry-instrumentation-sqlalchemy>=0.45b0
|
|
12
|
+
opentelemetry-instrumentation-redis>=0.45b0
|
|
13
|
+
opentelemetry-instrumentation-celery>=0.45b0
|
|
14
|
+
|
|
15
|
+
[ml]
|
|
16
|
+
pandas>=2.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "impulse-telemetry"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Observability SDK for Impulse microservices"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["observability", "telemetry", "opentelemetry", "prometheus", "fastapi", "ml"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: System :: Monitoring",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
dependencies = [
|
|
24
|
+
"opentelemetry-sdk>=1.24",
|
|
25
|
+
"opentelemetry-exporter-otlp-proto-grpc>=1.24",
|
|
26
|
+
"opentelemetry-propagator-b3>=1.24",
|
|
27
|
+
"opentelemetry-instrumentation-fastapi>=0.45b0",
|
|
28
|
+
"opentelemetry-instrumentation-requests>=0.45b0",
|
|
29
|
+
"opentelemetry-instrumentation-httpx>=0.45b0",
|
|
30
|
+
"prometheus-client>=0.20",
|
|
31
|
+
"structlog>=24.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
# For services using DBs, caches, queues
|
|
36
|
+
extras = [
|
|
37
|
+
"opentelemetry-instrumentation-sqlalchemy>=0.45b0",
|
|
38
|
+
"opentelemetry-instrumentation-redis>=0.45b0",
|
|
39
|
+
"opentelemetry-instrumentation-celery>=0.45b0",
|
|
40
|
+
]
|
|
41
|
+
# For ML services using data quality checks
|
|
42
|
+
ml = ["pandas>=2.0"]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["."]
|
|
46
|
+
include = ["impulse_telemetry*"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
testpaths = ["tests"]
|
|
50
|
+
# asyncio_mode = "auto" # uncomment if async tests are added later
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for impulse_telemetry.logging
|
|
3
|
+
|
|
4
|
+
Covers: get_logger, bind_request_context, clear_request_context, get_request_context.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from impulse_telemetry.logging import get_logger, bind_request_context, clear_request_context
|
|
8
|
+
from impulse_telemetry.logging.context import get_request_context
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_get_logger_returns_bound_logger():
|
|
12
|
+
log = get_logger(__name__)
|
|
13
|
+
assert log is not None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_log_does_not_raise():
|
|
17
|
+
log = get_logger(__name__)
|
|
18
|
+
log.info("test_event", key="value")
|
|
19
|
+
log.warning("test_warning", code=42)
|
|
20
|
+
log.error("test_error", reason="none")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_bind_injects_fields():
|
|
24
|
+
token = bind_request_context(user_id="u1", request_id="r1")
|
|
25
|
+
ctx = get_request_context()
|
|
26
|
+
assert ctx["user_id"] == "u1"
|
|
27
|
+
assert ctx["request_id"] == "r1"
|
|
28
|
+
clear_request_context(token)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_clear_restores_previous_context():
|
|
32
|
+
token = bind_request_context(user_id="u1")
|
|
33
|
+
clear_request_context(token)
|
|
34
|
+
ctx = get_request_context()
|
|
35
|
+
assert "user_id" not in ctx
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_context_empty_by_default():
|
|
39
|
+
ctx = get_request_context()
|
|
40
|
+
assert isinstance(ctx, dict)
|
|
41
|
+
# No stale fields from previous tests (each test gets its own call stack)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_nested_context_bind():
|
|
45
|
+
outer = bind_request_context(user_id="outer")
|
|
46
|
+
inner = bind_request_context(user_id="inner", extra="yes")
|
|
47
|
+
|
|
48
|
+
ctx = get_request_context()
|
|
49
|
+
assert ctx["user_id"] == "inner"
|
|
50
|
+
assert ctx["extra"] == "yes"
|
|
51
|
+
|
|
52
|
+
clear_request_context(inner)
|
|
53
|
+
ctx = get_request_context()
|
|
54
|
+
assert ctx["user_id"] == "outer"
|
|
55
|
+
assert "extra" not in ctx
|
|
56
|
+
|
|
57
|
+
clear_request_context(outer)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for impulse_telemetry.metrics
|
|
3
|
+
|
|
4
|
+
Covers: get_metrics singleton, labels(), system instruments, ML instruments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from impulse_telemetry.metrics import get_metrics
|
|
9
|
+
from impulse_telemetry.metrics.registry import Metrics
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_get_metrics_returns_instance(app):
|
|
13
|
+
m = get_metrics()
|
|
14
|
+
assert isinstance(m, Metrics)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_get_metrics_is_singleton(app):
|
|
18
|
+
assert get_metrics() is get_metrics()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_labels_merges_defaults(app):
|
|
22
|
+
m = get_metrics()
|
|
23
|
+
labels = m.labels(route="/predict", status="200")
|
|
24
|
+
assert labels["service"] == "test-svc"
|
|
25
|
+
assert labels["env"] == "dev"
|
|
26
|
+
assert labels["route"] == "/predict"
|
|
27
|
+
assert labels["status"] == "200"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_labels_defaults_only(app):
|
|
31
|
+
m = get_metrics()
|
|
32
|
+
labels = m.labels()
|
|
33
|
+
assert set(labels.keys()) == {"service", "env"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_system_instruments_present(app):
|
|
37
|
+
sys = get_metrics().system
|
|
38
|
+
assert sys.requests_total is not None
|
|
39
|
+
assert sys.errors_total is not None
|
|
40
|
+
assert sys.request_latency is not None
|
|
41
|
+
assert sys.active_requests is not None
|
|
42
|
+
assert sys.dependency_latency is not None
|
|
43
|
+
assert sys.dependency_errors is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_ml_instruments_present(app):
|
|
47
|
+
ml = get_metrics().ml
|
|
48
|
+
assert ml is not None
|
|
49
|
+
assert ml.inference_latency is not None
|
|
50
|
+
assert ml.inference_total is not None
|
|
51
|
+
assert ml.inference_errors is not None
|
|
52
|
+
assert ml.feature_drift is not None
|
|
53
|
+
assert ml.missing_feature_rate is not None
|
|
54
|
+
assert ml.rmse is not None
|
|
55
|
+
assert ml.precision is not None
|
|
56
|
+
assert ml.recall is not None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_counter_increment_does_not_raise(app):
|
|
60
|
+
m = get_metrics()
|
|
61
|
+
m.system.requests_total.labels(
|
|
62
|
+
**m.labels(route="/test", method="GET", status="200")
|
|
63
|
+
).inc()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_gauge_set_does_not_raise(app):
|
|
67
|
+
m = get_metrics()
|
|
68
|
+
m.system.queue_depth.labels(**m.labels(queue_name="default")).set(5)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for impulse_telemetry.middleware
|
|
3
|
+
|
|
4
|
+
Covers: RED metrics recording, X-Trace-Id header injection,
|
|
5
|
+
health-check path skipping, route pattern matching.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_200_response(client):
|
|
10
|
+
resp = client.get("/ping")
|
|
11
|
+
assert resp.status_code == 200
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_400_response(client):
|
|
15
|
+
resp = client.get("/fail")
|
|
16
|
+
assert resp.status_code == 400
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_trace_id_header_on_success(client):
|
|
20
|
+
resp = client.get("/ping")
|
|
21
|
+
# X-Trace-Id is injected by ImpulseMiddleware when an active span exists.
|
|
22
|
+
# With FastAPIInstrumentor wired up, a span is always created per request.
|
|
23
|
+
assert "x-trace-id" in resp.headers
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_trace_id_is_32_char_hex(client):
|
|
27
|
+
resp = client.get("/ping")
|
|
28
|
+
trace_id = resp.headers.get("x-trace-id", "")
|
|
29
|
+
assert len(trace_id) == 32
|
|
30
|
+
assert all(c in "0123456789abcdef" for c in trace_id)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_request_id_header_forwarded(client):
|
|
34
|
+
resp = client.get("/ping", headers={"x-request-id": "req-abc"})
|
|
35
|
+
assert resp.status_code == 200
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_health_check_paths_are_skipped(client):
|
|
39
|
+
# These paths exist in _SKIP — middleware calls call_next without recording metrics.
|
|
40
|
+
# They 404 because we didn't register them as routes, which is fine;
|
|
41
|
+
# the important thing is that get_metrics() is not called for them.
|
|
42
|
+
for path in ("/healthz", "/readyz", "/livez"):
|
|
43
|
+
resp = client.get(path)
|
|
44
|
+
assert resp.status_code == 404 # route not found, not a middleware error
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_route_pattern_used_not_raw_url(client):
|
|
48
|
+
# /users/123 and /users/456 should both match route pattern /users/{user_id}
|
|
49
|
+
resp1 = client.get("/users/123")
|
|
50
|
+
resp2 = client.get("/users/456")
|
|
51
|
+
assert resp1.status_code == 200
|
|
52
|
+
assert resp2.status_code == 200
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for impulse_telemetry.ml
|
|
3
|
+
|
|
4
|
+
Covers: MLMonitor.inference() context manager, @trace decorator,
|
|
5
|
+
record_drift, record_performance, data quality checks, error handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from impulse_telemetry.ml import MLMonitor
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture(scope="module")
|
|
13
|
+
def monitor(app): # app ensures init(enable_ml=True) ran first
|
|
14
|
+
return MLMonitor(model_id="test-model", user_id="test-user")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_inference_context_manager(monitor):
|
|
18
|
+
with monitor.inference() as span:
|
|
19
|
+
assert span is not None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_inference_span_set(monitor):
|
|
23
|
+
with monitor.inference() as span:
|
|
24
|
+
span.set("custom_attr", "hello")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_inference_record_output_list(monitor):
|
|
28
|
+
with monitor.inference() as span:
|
|
29
|
+
span.record_output([0.1, 0.9, 0.5])
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_inference_record_output_no_len(monitor):
|
|
33
|
+
# record_output is best-effort — scalars should not raise
|
|
34
|
+
with monitor.inference() as span:
|
|
35
|
+
span.record_output(0.42)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_inference_propagates_exception(monitor):
|
|
39
|
+
with pytest.raises(ValueError, match="boom"):
|
|
40
|
+
with monitor.inference():
|
|
41
|
+
raise ValueError("boom")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_trace_decorator(monitor):
|
|
45
|
+
@monitor.trace
|
|
46
|
+
def predict(x):
|
|
47
|
+
return x * 2
|
|
48
|
+
|
|
49
|
+
assert predict(5) == 10
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_trace_decorator_propagates_exception(monitor):
|
|
53
|
+
@monitor.trace
|
|
54
|
+
def bad():
|
|
55
|
+
raise RuntimeError("fail")
|
|
56
|
+
|
|
57
|
+
with pytest.raises(RuntimeError):
|
|
58
|
+
bad()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_record_drift_does_not_raise(monitor):
|
|
62
|
+
monitor.record_drift("age", score=0.18)
|
|
63
|
+
monitor.record_drift("income", score=0.05, metric="kl")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_record_performance_does_not_raise(monitor):
|
|
67
|
+
monitor.record_performance(rmse=0.042, precision=0.87, recall=0.81)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_record_performance_partial(monitor):
|
|
71
|
+
monitor.record_performance(rmse=0.1)
|
|
72
|
+
monitor.record_performance(precision=0.9)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_inference_with_clean_dataframe(monitor):
|
|
76
|
+
pd = pytest.importorskip("pandas")
|
|
77
|
+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
|
|
78
|
+
with monitor.inference(features=df) as span:
|
|
79
|
+
span.record_output([0.1, 0.2, 0.3])
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_inference_with_missing_values_warns(monitor):
|
|
83
|
+
pd = pytest.importorskip("pandas")
|
|
84
|
+
df = pd.DataFrame({"a": [1, None, 3], "b": [None, None, 6]})
|
|
85
|
+
# Missing values are recorded as metrics + logged as warnings — should not raise
|
|
86
|
+
with monitor.inference(features=df):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_inference_with_empty_dataframe(monitor):
|
|
91
|
+
pd = pytest.importorskip("pandas")
|
|
92
|
+
df = pd.DataFrame()
|
|
93
|
+
with monitor.inference(features=df):
|
|
94
|
+
pass
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for impulse_telemetry.tracing
|
|
3
|
+
|
|
4
|
+
Covers: current_span_context, inject_headers, get_tracer.
|
|
5
|
+
Uses a local TracerProvider + InMemorySpanExporter so tests are
|
|
6
|
+
self-contained and don't require an OTLP collector.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
10
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
11
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
12
|
+
|
|
13
|
+
from impulse_telemetry.tracing import get_tracer, current_span_context, inject_headers
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _local_tracer():
|
|
17
|
+
"""Create an isolated tracer backed by an in-memory exporter."""
|
|
18
|
+
exporter = InMemorySpanExporter()
|
|
19
|
+
provider = TracerProvider()
|
|
20
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
21
|
+
return provider.get_tracer("test"), exporter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_no_active_span_returns_empty_strings():
|
|
25
|
+
ctx = current_span_context()
|
|
26
|
+
assert ctx == {"trace_id": "", "span_id": ""}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_active_span_returns_hex_ids():
|
|
30
|
+
tracer, _ = _local_tracer()
|
|
31
|
+
with tracer.start_as_current_span("op"):
|
|
32
|
+
ctx = current_span_context()
|
|
33
|
+
assert len(ctx["trace_id"]) == 32
|
|
34
|
+
assert len(ctx["span_id"]) == 16
|
|
35
|
+
assert all(c in "0123456789abcdef" for c in ctx["trace_id"])
|
|
36
|
+
assert all(c in "0123456789abcdef" for c in ctx["span_id"])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_inject_headers_adds_traceparent():
|
|
40
|
+
tracer, _ = _local_tracer()
|
|
41
|
+
with tracer.start_as_current_span("op"):
|
|
42
|
+
headers = inject_headers({})
|
|
43
|
+
assert "traceparent" in headers
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_inject_headers_no_span_returns_empty():
|
|
47
|
+
headers = inject_headers({})
|
|
48
|
+
# No active span — propagator injects nothing (or a zero traceparent)
|
|
49
|
+
assert isinstance(headers, dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_get_tracer_returns_tracer():
|
|
53
|
+
tracer = get_tracer(__name__)
|
|
54
|
+
assert tracer is not None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_span_can_set_attributes():
|
|
58
|
+
tracer, exporter = _local_tracer()
|
|
59
|
+
with tracer.start_as_current_span("op") as span:
|
|
60
|
+
span.set_attribute("key", "value")
|
|
61
|
+
span.set_attribute("count", 42)
|
|
62
|
+
|
|
63
|
+
finished = exporter.get_finished_spans()
|
|
64
|
+
assert len(finished) == 1
|
|
65
|
+
assert finished[0].attributes["key"] == "value"
|
|
66
|
+
assert finished[0].attributes["count"] == 42
|