nodus-observability 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.
- nodus_observability-0.1.0/LICENSE +21 -0
- nodus_observability-0.1.0/PKG-INFO +99 -0
- nodus_observability-0.1.0/README.md +66 -0
- nodus_observability-0.1.0/nodus_observability/__init__.py +81 -0
- nodus_observability-0.1.0/nodus_observability/context.py +109 -0
- nodus_observability-0.1.0/nodus_observability/logging.py +165 -0
- nodus_observability-0.1.0/nodus_observability/metrics.py +49 -0
- nodus_observability-0.1.0/nodus_observability/otel.py +109 -0
- nodus_observability-0.1.0/nodus_observability.egg-info/PKG-INFO +99 -0
- nodus_observability-0.1.0/nodus_observability.egg-info/SOURCES.txt +16 -0
- nodus_observability-0.1.0/nodus_observability.egg-info/dependency_links.txt +1 -0
- nodus_observability-0.1.0/nodus_observability.egg-info/requires.txt +19 -0
- nodus_observability-0.1.0/nodus_observability.egg-info/top_level.txt +1 -0
- nodus_observability-0.1.0/pyproject.toml +41 -0
- nodus_observability-0.1.0/setup.cfg +4 -0
- nodus_observability-0.1.0/tests/test_context.py +130 -0
- nodus_observability-0.1.0/tests/test_logging.py +68 -0
- nodus_observability-0.1.0/tests/test_otel.py +46 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Shawn Knight
|
|
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.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodus-observability
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OTel tracing, Prometheus metrics, structured JSON logging, and trace ContextVars
|
|
5
|
+
Author: Shawn Knight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Masterplanner25/nodus-observability
|
|
8
|
+
Project-URL: Repository, https://github.com/Masterplanner25/nodus-observability
|
|
9
|
+
Keywords: observability,opentelemetry,prometheus,logging,tracing,nodus
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: logging
|
|
19
|
+
Requires-Dist: python-json-logger>=2.0.0; extra == "logging"
|
|
20
|
+
Provides-Extra: otel
|
|
21
|
+
Requires-Dist: opentelemetry-api>=1.0.0; extra == "otel"
|
|
22
|
+
Requires-Dist: opentelemetry-sdk>=1.0.0; extra == "otel"
|
|
23
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.0.0; extra == "otel"
|
|
24
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.60b0; extra == "otel"
|
|
25
|
+
Provides-Extra: metrics
|
|
26
|
+
Requires-Dist: prometheus-client>=0.10.0; extra == "metrics"
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: nodus-observability[logging,metrics,otel]; extra == "all"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: python-json-logger>=2.0.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# nodus-observability
|
|
35
|
+
|
|
36
|
+
OpenTelemetry bootstrap, Prometheus registry, structured JSON logging, and async-safe trace ContextVars. Zero required dependencies beyond `python-json-logger` — OTel and Prometheus are optional extras.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install nodus-observability # core only
|
|
42
|
+
pip install "nodus-observability[metrics]" # + prometheus-client
|
|
43
|
+
pip install "nodus-observability[otel]" # + opentelemetry stack
|
|
44
|
+
pip install "nodus-observability[all]" # everything
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Trace context
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from nodus_observability import set_trace_id, get_trace_id, reset_trace_id, ensure_trace_id
|
|
51
|
+
|
|
52
|
+
tok = set_trace_id("req-abc-123")
|
|
53
|
+
print(get_trace_id()) # "req-abc-123"
|
|
54
|
+
reset_trace_id(tok)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Structured logging
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from nodus_observability import configure_logging, get_trace_id
|
|
61
|
+
|
|
62
|
+
configure_logging(
|
|
63
|
+
env="production",
|
|
64
|
+
log_level="INFO",
|
|
65
|
+
get_trace_id_fn=get_trace_id, # inject trace_id from ContextVar
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## OTel tracing
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from nodus_observability import init_otel, get_tracer
|
|
73
|
+
|
|
74
|
+
init_otel(service_name="my-service") # reads OTEL_EXPORTER_OTLP_ENDPOINT
|
|
75
|
+
tracer = get_tracer("my-module")
|
|
76
|
+
|
|
77
|
+
with tracer.start_as_current_span("my-operation") as span:
|
|
78
|
+
span.set_status("ok")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Prometheus metrics
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from nodus_observability import create_registry, Counter
|
|
85
|
+
|
|
86
|
+
REGISTRY = create_registry() # never use the default global registry
|
|
87
|
+
|
|
88
|
+
requests_total = Counter(
|
|
89
|
+
"myapp_requests_total",
|
|
90
|
+
"Total requests",
|
|
91
|
+
["method", "status"],
|
|
92
|
+
registry=REGISTRY,
|
|
93
|
+
)
|
|
94
|
+
requests_total.labels(method="GET", status="200").inc()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Extracted from
|
|
98
|
+
|
|
99
|
+
`AINDY/platform_layer/trace_context.py`, `otel.py`, `metrics.py`, and `log_config.py` in the A.I.N.D.Y. runtime.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# nodus-observability
|
|
2
|
+
|
|
3
|
+
OpenTelemetry bootstrap, Prometheus registry, structured JSON logging, and async-safe trace ContextVars. Zero required dependencies beyond `python-json-logger` — OTel and Prometheus are optional extras.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install nodus-observability # core only
|
|
9
|
+
pip install "nodus-observability[metrics]" # + prometheus-client
|
|
10
|
+
pip install "nodus-observability[otel]" # + opentelemetry stack
|
|
11
|
+
pip install "nodus-observability[all]" # everything
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Trace context
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from nodus_observability import set_trace_id, get_trace_id, reset_trace_id, ensure_trace_id
|
|
18
|
+
|
|
19
|
+
tok = set_trace_id("req-abc-123")
|
|
20
|
+
print(get_trace_id()) # "req-abc-123"
|
|
21
|
+
reset_trace_id(tok)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Structured logging
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
from nodus_observability import configure_logging, get_trace_id
|
|
28
|
+
|
|
29
|
+
configure_logging(
|
|
30
|
+
env="production",
|
|
31
|
+
log_level="INFO",
|
|
32
|
+
get_trace_id_fn=get_trace_id, # inject trace_id from ContextVar
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## OTel tracing
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from nodus_observability import init_otel, get_tracer
|
|
40
|
+
|
|
41
|
+
init_otel(service_name="my-service") # reads OTEL_EXPORTER_OTLP_ENDPOINT
|
|
42
|
+
tracer = get_tracer("my-module")
|
|
43
|
+
|
|
44
|
+
with tracer.start_as_current_span("my-operation") as span:
|
|
45
|
+
span.set_status("ok")
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Prometheus metrics
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from nodus_observability import create_registry, Counter
|
|
52
|
+
|
|
53
|
+
REGISTRY = create_registry() # never use the default global registry
|
|
54
|
+
|
|
55
|
+
requests_total = Counter(
|
|
56
|
+
"myapp_requests_total",
|
|
57
|
+
"Total requests",
|
|
58
|
+
["method", "status"],
|
|
59
|
+
registry=REGISTRY,
|
|
60
|
+
)
|
|
61
|
+
requests_total.labels(method="GET", status="200").inc()
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Extracted from
|
|
65
|
+
|
|
66
|
+
`AINDY/platform_layer/trace_context.py`, `otel.py`, `metrics.py`, and `log_config.py` in the A.I.N.D.Y. runtime.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""nodus-observability — OTel tracing, Prometheus metrics, structured logging, trace context.
|
|
2
|
+
|
|
3
|
+
Trace context (ContextVars — zero dependencies):
|
|
4
|
+
get_trace_id / set_trace_id / reset_trace_id / ensure_trace_id
|
|
5
|
+
get_parent_event_id / set_parent_event_id / reset_parent_event_id
|
|
6
|
+
is_pipeline_active / set_pipeline_active / reset_pipeline_active
|
|
7
|
+
get_current_request / set_current_request / reset_current_request
|
|
8
|
+
get_current_execution_context / set_current_execution_context / reset_current_execution_context
|
|
9
|
+
|
|
10
|
+
OTel tracing (optional — install with [otel] extra):
|
|
11
|
+
init_otel(service_name) — bootstrap TracerProvider + OTLP exporter
|
|
12
|
+
get_tracer(name) — retrieve tracer (noop when OTEL absent)
|
|
13
|
+
span_context_from_trace_id — convert hex trace ID to SpanContext
|
|
14
|
+
|
|
15
|
+
Prometheus metrics (optional — install with [metrics] extra):
|
|
16
|
+
create_registry() — fresh CollectorRegistry (never use the global one)
|
|
17
|
+
Counter, Histogram, Gauge — re-exported from prometheus_client
|
|
18
|
+
|
|
19
|
+
Structured logging:
|
|
20
|
+
configure_logging(...) — set up root logger with JSON + correlation fields
|
|
21
|
+
"""
|
|
22
|
+
from .context import (
|
|
23
|
+
ensure_trace_id,
|
|
24
|
+
get_current_execution_context,
|
|
25
|
+
get_current_request,
|
|
26
|
+
get_current_trace_id,
|
|
27
|
+
get_parent_event_id,
|
|
28
|
+
get_trace_id,
|
|
29
|
+
is_pipeline_active,
|
|
30
|
+
reset_current_execution_context,
|
|
31
|
+
reset_current_request,
|
|
32
|
+
reset_current_trace_id,
|
|
33
|
+
reset_parent_event_id,
|
|
34
|
+
reset_pipeline_active,
|
|
35
|
+
reset_trace_id,
|
|
36
|
+
set_current_execution_context,
|
|
37
|
+
set_current_request,
|
|
38
|
+
set_current_trace_id,
|
|
39
|
+
set_parent_event_id,
|
|
40
|
+
set_pipeline_active,
|
|
41
|
+
set_trace_id,
|
|
42
|
+
)
|
|
43
|
+
from .logging import configure_logging
|
|
44
|
+
from .metrics import Counter, Gauge, Histogram, create_registry
|
|
45
|
+
from .otel import _NoopSpan, _NoopTracer, get_tracer, init_otel, span_context_from_trace_id
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# Context
|
|
49
|
+
"ensure_trace_id",
|
|
50
|
+
"get_current_execution_context",
|
|
51
|
+
"get_current_request",
|
|
52
|
+
"get_current_trace_id",
|
|
53
|
+
"get_parent_event_id",
|
|
54
|
+
"get_trace_id",
|
|
55
|
+
"is_pipeline_active",
|
|
56
|
+
"reset_current_execution_context",
|
|
57
|
+
"reset_current_request",
|
|
58
|
+
"reset_current_trace_id",
|
|
59
|
+
"reset_parent_event_id",
|
|
60
|
+
"reset_pipeline_active",
|
|
61
|
+
"reset_trace_id",
|
|
62
|
+
"set_current_execution_context",
|
|
63
|
+
"set_current_request",
|
|
64
|
+
"set_current_trace_id",
|
|
65
|
+
"set_parent_event_id",
|
|
66
|
+
"set_pipeline_active",
|
|
67
|
+
"set_trace_id",
|
|
68
|
+
# Logging
|
|
69
|
+
"configure_logging",
|
|
70
|
+
# Metrics
|
|
71
|
+
"Counter",
|
|
72
|
+
"Gauge",
|
|
73
|
+
"Histogram",
|
|
74
|
+
"create_registry",
|
|
75
|
+
# OTel
|
|
76
|
+
"_NoopSpan",
|
|
77
|
+
"_NoopTracer",
|
|
78
|
+
"get_tracer",
|
|
79
|
+
"init_otel",
|
|
80
|
+
"span_context_from_trace_id",
|
|
81
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Async-safe trace and execution context via Python ContextVars."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from contextvars import ContextVar, Token
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_trace_id_ctx: ContextVar[str] = ContextVar("trace_id", default="-")
|
|
9
|
+
_parent_event_id_ctx: ContextVar[str] = ContextVar("parent_event_id", default="-")
|
|
10
|
+
_pipeline_active_ctx: ContextVar[bool] = ContextVar("pipeline_active", default=False)
|
|
11
|
+
_current_request_ctx: ContextVar[Any] = ContextVar("current_request", default=None)
|
|
12
|
+
_current_execution_context_ctx: ContextVar[Any] = ContextVar(
|
|
13
|
+
"current_execution_context", default=None
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_trace_id(default: str | None = None) -> str | None:
|
|
18
|
+
trace_id = _trace_id_ctx.get()
|
|
19
|
+
if trace_id == "-":
|
|
20
|
+
return default
|
|
21
|
+
return trace_id
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def set_trace_id(trace_id: str) -> Token:
|
|
25
|
+
return _trace_id_ctx.set(str(trace_id))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def reset_trace_id(token: Token) -> None:
|
|
29
|
+
_trace_id_ctx.reset(token)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_trace_id(trace_id: str | None = None) -> str:
|
|
33
|
+
"""Return existing trace ID or generate/set a new one."""
|
|
34
|
+
current = get_trace_id()
|
|
35
|
+
if current:
|
|
36
|
+
return current
|
|
37
|
+
generated = str(trace_id or uuid.uuid4())
|
|
38
|
+
_trace_id_ctx.set(generated)
|
|
39
|
+
return generated
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_parent_event_id(default: str | None = None) -> str | None:
|
|
43
|
+
parent_event_id = _parent_event_id_ctx.get()
|
|
44
|
+
if parent_event_id == "-":
|
|
45
|
+
return default
|
|
46
|
+
return parent_event_id
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def set_parent_event_id(parent_event_id: str | None) -> Token:
|
|
50
|
+
return _parent_event_id_ctx.set("-" if not parent_event_id else str(parent_event_id))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reset_parent_event_id(token: Token) -> None:
|
|
54
|
+
_parent_event_id_ctx.reset(token)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_pipeline_active() -> bool:
|
|
58
|
+
return bool(_pipeline_active_ctx.get())
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def set_pipeline_active(active: bool = True) -> Token:
|
|
62
|
+
return _pipeline_active_ctx.set(bool(active))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reset_pipeline_active(token: Token) -> None:
|
|
66
|
+
_pipeline_active_ctx.reset(token)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def get_current_request(default: Any = None) -> Any:
|
|
70
|
+
current = _current_request_ctx.get()
|
|
71
|
+
if current is None:
|
|
72
|
+
return default
|
|
73
|
+
return current
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_current_request(request: Any) -> Token:
|
|
77
|
+
return _current_request_ctx.set(request)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def reset_current_request(token: Token) -> None:
|
|
81
|
+
_current_request_ctx.reset(token)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_current_execution_context(default: Any = None) -> Any:
|
|
85
|
+
current = _current_execution_context_ctx.get()
|
|
86
|
+
if current is None:
|
|
87
|
+
return default
|
|
88
|
+
return current
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def set_current_execution_context(context: Any) -> Token:
|
|
92
|
+
return _current_execution_context_ctx.set(context)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def reset_current_execution_context(token: Token) -> None:
|
|
96
|
+
_current_execution_context_ctx.reset(token)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Aliases — prefer the shorter names in new code
|
|
100
|
+
def get_current_trace_id(default: str | None = None) -> str | None:
|
|
101
|
+
return get_trace_id(default=default)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def set_current_trace_id(trace_id: str) -> Token:
|
|
105
|
+
return set_trace_id(trace_id)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def reset_current_trace_id(token: Token) -> None:
|
|
109
|
+
reset_trace_id(token)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Structured JSON logging with pluggable correlation context."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
_HANDLER_MARKER = "_nodus_structured_logging_handler"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _CorrelationFilter(logging.Filter):
|
|
12
|
+
"""Inject trace_id, user_id, and env into every log record.
|
|
13
|
+
|
|
14
|
+
Fields added (all strings):
|
|
15
|
+
``trace_id`` — from ``get_trace_id_fn()`` or empty string
|
|
16
|
+
``user_id`` — from ``get_request_fn()`` / ``get_context_fn()`` or empty string
|
|
17
|
+
``env`` — deployment environment label
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
env: str = "development",
|
|
23
|
+
*,
|
|
24
|
+
get_trace_id_fn: Optional[Callable[[], Optional[str]]] = None,
|
|
25
|
+
get_request_fn: Optional[Callable[[], Any]] = None,
|
|
26
|
+
get_context_fn: Optional[Callable[[], Any]] = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self._env = env
|
|
30
|
+
self._get_trace_id = get_trace_id_fn or (lambda: None)
|
|
31
|
+
self._get_request = get_request_fn or (lambda: None)
|
|
32
|
+
self._get_context = get_context_fn or (lambda: None)
|
|
33
|
+
|
|
34
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
35
|
+
try:
|
|
36
|
+
record.trace_id = self._get_trace_id() or ""
|
|
37
|
+
request = self._get_request()
|
|
38
|
+
request_state = getattr(request, "state", None)
|
|
39
|
+
record.user_id = ""
|
|
40
|
+
if request_state is not None:
|
|
41
|
+
record.user_id = str(getattr(request_state, "user_id", "") or "")
|
|
42
|
+
if not record.user_id:
|
|
43
|
+
execution_context = self._get_context()
|
|
44
|
+
if isinstance(execution_context, dict):
|
|
45
|
+
record.user_id = str(execution_context.get("user_id", "") or "")
|
|
46
|
+
elif execution_context is not None:
|
|
47
|
+
record.user_id = str(getattr(execution_context, "user_id", "") or "")
|
|
48
|
+
except Exception:
|
|
49
|
+
record.trace_id = ""
|
|
50
|
+
record.user_id = ""
|
|
51
|
+
record.env = self._env
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def configure_logging(
|
|
56
|
+
*,
|
|
57
|
+
env: str = "development",
|
|
58
|
+
log_level: str = "INFO",
|
|
59
|
+
json_logs: Optional[bool] = None,
|
|
60
|
+
force: bool = False,
|
|
61
|
+
get_trace_id_fn: Optional[Callable[[], Optional[str]]] = None,
|
|
62
|
+
get_request_fn: Optional[Callable[[], Any]] = None,
|
|
63
|
+
get_context_fn: Optional[Callable[[], Any]] = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Configure the root logger with structured correlation output.
|
|
66
|
+
|
|
67
|
+
``json_logs`` defaults to True in production/staging, False in dev/test.
|
|
68
|
+
Override via the ``LOG_FORMAT`` environment variable (``json`` or ``text``).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
env: Deployment environment name (``"development"``, ``"production"``, etc.).
|
|
72
|
+
log_level: Root log level string (``"DEBUG"``, ``"INFO"``, etc.).
|
|
73
|
+
json_logs: Force JSON (True) or plain text (False). Auto-detected when None.
|
|
74
|
+
force: Replace existing handlers even when one already exists.
|
|
75
|
+
get_trace_id_fn: Callable returning the current trace ID string or None.
|
|
76
|
+
get_request_fn: Callable returning the current HTTP request object or None.
|
|
77
|
+
get_context_fn: Callable returning the current execution context or None.
|
|
78
|
+
|
|
79
|
+
Usage with nodus-observability's own context module::
|
|
80
|
+
|
|
81
|
+
from nodus_observability import configure_logging, get_trace_id
|
|
82
|
+
configure_logging(env="development", get_trace_id_fn=get_trace_id)
|
|
83
|
+
|
|
84
|
+
Usage with AINDY trace_context::
|
|
85
|
+
|
|
86
|
+
from nodus_observability import configure_logging
|
|
87
|
+
from AINDY.platform_layer.trace_context import get_current_trace_id, get_current_request, get_current_execution_context
|
|
88
|
+
configure_logging(
|
|
89
|
+
env="production",
|
|
90
|
+
get_trace_id_fn=get_current_trace_id,
|
|
91
|
+
get_request_fn=get_current_request,
|
|
92
|
+
get_context_fn=get_current_execution_context,
|
|
93
|
+
)
|
|
94
|
+
"""
|
|
95
|
+
if json_logs is None:
|
|
96
|
+
fmt_env = os.getenv("LOG_FORMAT", "").lower()
|
|
97
|
+
if fmt_env == "json":
|
|
98
|
+
json_logs = True
|
|
99
|
+
elif fmt_env == "text":
|
|
100
|
+
json_logs = False
|
|
101
|
+
else:
|
|
102
|
+
json_logs = env.lower() in {"production", "prod", "staging"}
|
|
103
|
+
|
|
104
|
+
correlation_filter = _CorrelationFilter(
|
|
105
|
+
env=env,
|
|
106
|
+
get_trace_id_fn=get_trace_id_fn,
|
|
107
|
+
get_request_fn=get_request_fn,
|
|
108
|
+
get_context_fn=get_context_fn,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if json_logs:
|
|
112
|
+
try:
|
|
113
|
+
from pythonjsonlogger import jsonlogger
|
|
114
|
+
|
|
115
|
+
formatter = jsonlogger.JsonFormatter(
|
|
116
|
+
fmt="%(asctime)s %(levelname)s %(name)s %(message)s %(trace_id)s %(user_id)s %(env)s",
|
|
117
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
118
|
+
rename_fields={
|
|
119
|
+
"asctime": "timestamp",
|
|
120
|
+
"levelname": "level",
|
|
121
|
+
"name": "logger",
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
except ImportError:
|
|
125
|
+
formatter = logging.Formatter(
|
|
126
|
+
"%(asctime)s %(levelname)s %(name)s [trace_id=%(trace_id)s user_id=%(user_id)s env=%(env)s] %(message)s",
|
|
127
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
formatter = logging.Formatter(
|
|
131
|
+
"%(asctime)s %(levelname)s %(name)s [trace_id=%(trace_id)s user_id=%(user_id)s env=%(env)s] %(message)s",
|
|
132
|
+
datefmt="%Y-%m-%dT%H:%M:%S",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
handler = logging.StreamHandler()
|
|
136
|
+
handler.setFormatter(formatter)
|
|
137
|
+
handler.addFilter(correlation_filter)
|
|
138
|
+
setattr(handler, _HANDLER_MARKER, True)
|
|
139
|
+
|
|
140
|
+
root = logging.getLogger()
|
|
141
|
+
current_handler = next(
|
|
142
|
+
(
|
|
143
|
+
candidate
|
|
144
|
+
for candidate in root.handlers
|
|
145
|
+
if getattr(candidate, _HANDLER_MARKER, False)
|
|
146
|
+
),
|
|
147
|
+
None,
|
|
148
|
+
)
|
|
149
|
+
should_replace = force or current_handler is None or len(root.handlers) != 1
|
|
150
|
+
|
|
151
|
+
if should_replace:
|
|
152
|
+
root.handlers.clear()
|
|
153
|
+
root.addHandler(handler)
|
|
154
|
+
else:
|
|
155
|
+
current_handler.setFormatter(formatter)
|
|
156
|
+
for existing_filter in list(current_handler.filters):
|
|
157
|
+
current_handler.removeFilter(existing_filter)
|
|
158
|
+
current_handler.addFilter(correlation_filter)
|
|
159
|
+
|
|
160
|
+
root.setLevel(getattr(logging, log_level.upper(), logging.INFO))
|
|
161
|
+
|
|
162
|
+
if env.lower() in {"production", "prod"}:
|
|
163
|
+
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
|
164
|
+
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
|
165
|
+
logging.getLogger("apscheduler").setLevel(logging.WARNING)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Prometheus metrics helpers.
|
|
2
|
+
|
|
3
|
+
The core pattern: always use a dedicated ``CollectorRegistry`` rather than
|
|
4
|
+
the default global registry. This prevents metric registration conflicts
|
|
5
|
+
when multiple libraries or tests share the same Python process.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from nodus_observability import create_registry, Counter, Histogram, Gauge
|
|
10
|
+
|
|
11
|
+
REGISTRY = create_registry()
|
|
12
|
+
|
|
13
|
+
requests_total = Counter(
|
|
14
|
+
"myapp_requests_total",
|
|
15
|
+
"Total HTTP requests",
|
|
16
|
+
["method", "status"],
|
|
17
|
+
registry=REGISTRY,
|
|
18
|
+
)
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram
|
|
24
|
+
|
|
25
|
+
_PROMETHEUS_AVAILABLE = True
|
|
26
|
+
except ImportError:
|
|
27
|
+
CollectorRegistry = None # type: ignore[assignment,misc]
|
|
28
|
+
Counter = None # type: ignore[assignment,misc]
|
|
29
|
+
Gauge = None # type: ignore[assignment,misc]
|
|
30
|
+
Histogram = None # type: ignore[assignment,misc]
|
|
31
|
+
_PROMETHEUS_AVAILABLE = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def create_registry() -> "CollectorRegistry":
|
|
35
|
+
"""Return a fresh Prometheus CollectorRegistry.
|
|
36
|
+
|
|
37
|
+
Always prefer a dedicated registry over the default global one to avoid
|
|
38
|
+
metric name conflicts across libraries and test runs.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
ImportError: If ``prometheus-client`` is not installed. Install with
|
|
42
|
+
``pip install 'nodus-observability[metrics]'``.
|
|
43
|
+
"""
|
|
44
|
+
if not _PROMETHEUS_AVAILABLE:
|
|
45
|
+
raise ImportError(
|
|
46
|
+
"prometheus-client is required for metrics support. "
|
|
47
|
+
"Install with: pip install 'nodus-observability[metrics]'"
|
|
48
|
+
)
|
|
49
|
+
return CollectorRegistry(auto_describe=True)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""OpenTelemetry bootstrap with noop fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
_initialized = False
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
|
|
13
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
14
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
15
|
+
|
|
16
|
+
_OTEL_AVAILABLE = True
|
|
17
|
+
except ImportError:
|
|
18
|
+
trace = None
|
|
19
|
+
Resource = None
|
|
20
|
+
SERVICE_NAME = None
|
|
21
|
+
TracerProvider = None
|
|
22
|
+
BatchSpanProcessor = None
|
|
23
|
+
_OTEL_AVAILABLE = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _NoopSpan:
|
|
27
|
+
def __enter__(self):
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def __exit__(self, exc_type, exc, tb):
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
def set_status(self, *args, **kwargs):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def record_exception(self, *args, **kwargs):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _NoopTracer:
|
|
41
|
+
def start_as_current_span(self, *args, **kwargs):
|
|
42
|
+
return _NoopSpan()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def init_otel(service_name: str = "app") -> None:
|
|
46
|
+
"""Initialize OTEL TracerProvider. Safe to call multiple times (idempotent).
|
|
47
|
+
|
|
48
|
+
Reads ``OTEL_EXPORTER_OTLP_ENDPOINT`` from the environment. When the
|
|
49
|
+
variable is not set, tracing is a no-op. When the opentelemetry packages
|
|
50
|
+
are not installed, tracing is silently disabled.
|
|
51
|
+
"""
|
|
52
|
+
global _initialized
|
|
53
|
+
if _initialized:
|
|
54
|
+
return
|
|
55
|
+
if not _OTEL_AVAILABLE:
|
|
56
|
+
logger.info("[otel] OpenTelemetry packages not installed — tracing is no-op")
|
|
57
|
+
_initialized = True
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
resource = Resource.create({SERVICE_NAME: service_name})
|
|
61
|
+
provider = TracerProvider(resource=resource)
|
|
62
|
+
|
|
63
|
+
otlp_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
|
64
|
+
if otlp_endpoint:
|
|
65
|
+
try:
|
|
66
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
67
|
+
OTLPSpanExporter,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
exporter = OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True)
|
|
71
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
72
|
+
logger.info("[otel] OTLP exporter configured: %s", otlp_endpoint)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
logger.warning("[otel] OTLP exporter setup failed (tracing disabled): %s", exc)
|
|
75
|
+
else:
|
|
76
|
+
logger.info("[otel] OTEL_EXPORTER_OTLP_ENDPOINT not set — tracing is no-op")
|
|
77
|
+
|
|
78
|
+
trace.set_tracer_provider(provider)
|
|
79
|
+
_initialized = True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_tracer(name: str = "app"):
|
|
83
|
+
"""Return a tracer. Returns a no-op tracer when OTEL is unavailable."""
|
|
84
|
+
if not _OTEL_AVAILABLE:
|
|
85
|
+
return _NoopTracer()
|
|
86
|
+
return trace.get_tracer(name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def span_context_from_trace_id(trace_id_hex: str | None):
|
|
90
|
+
"""Convert a hex trace ID string to an OTEL SpanContext for linking.
|
|
91
|
+
|
|
92
|
+
Returns None when OTEL is unavailable or the ID is not parseable.
|
|
93
|
+
"""
|
|
94
|
+
if not _OTEL_AVAILABLE or not trace_id_hex:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
tid = int(trace_id_hex.replace("-", "")[:32], 16)
|
|
98
|
+
sid = tid & ((1 << 64) - 1)
|
|
99
|
+
if sid == 0:
|
|
100
|
+
sid = 1
|
|
101
|
+
return trace.SpanContext(
|
|
102
|
+
trace_id=tid,
|
|
103
|
+
span_id=sid,
|
|
104
|
+
is_remote=True,
|
|
105
|
+
trace_flags=trace.TraceFlags(trace.TraceFlags.SAMPLED),
|
|
106
|
+
trace_state=trace.TraceState(),
|
|
107
|
+
)
|
|
108
|
+
except Exception:
|
|
109
|
+
return None
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nodus-observability
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OTel tracing, Prometheus metrics, structured JSON logging, and trace ContextVars
|
|
5
|
+
Author: Shawn Knight
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Masterplanner25/nodus-observability
|
|
8
|
+
Project-URL: Repository, https://github.com/Masterplanner25/nodus-observability
|
|
9
|
+
Keywords: observability,opentelemetry,prometheus,logging,tracing,nodus
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: logging
|
|
19
|
+
Requires-Dist: python-json-logger>=2.0.0; extra == "logging"
|
|
20
|
+
Provides-Extra: otel
|
|
21
|
+
Requires-Dist: opentelemetry-api>=1.0.0; extra == "otel"
|
|
22
|
+
Requires-Dist: opentelemetry-sdk>=1.0.0; extra == "otel"
|
|
23
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.0.0; extra == "otel"
|
|
24
|
+
Requires-Dist: opentelemetry-instrumentation-fastapi>=0.60b0; extra == "otel"
|
|
25
|
+
Provides-Extra: metrics
|
|
26
|
+
Requires-Dist: prometheus-client>=0.10.0; extra == "metrics"
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: nodus-observability[logging,metrics,otel]; extra == "all"
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: python-json-logger>=2.0.0; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# nodus-observability
|
|
35
|
+
|
|
36
|
+
OpenTelemetry bootstrap, Prometheus registry, structured JSON logging, and async-safe trace ContextVars. Zero required dependencies beyond `python-json-logger` — OTel and Prometheus are optional extras.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install nodus-observability # core only
|
|
42
|
+
pip install "nodus-observability[metrics]" # + prometheus-client
|
|
43
|
+
pip install "nodus-observability[otel]" # + opentelemetry stack
|
|
44
|
+
pip install "nodus-observability[all]" # everything
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Trace context
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from nodus_observability import set_trace_id, get_trace_id, reset_trace_id, ensure_trace_id
|
|
51
|
+
|
|
52
|
+
tok = set_trace_id("req-abc-123")
|
|
53
|
+
print(get_trace_id()) # "req-abc-123"
|
|
54
|
+
reset_trace_id(tok)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Structured logging
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from nodus_observability import configure_logging, get_trace_id
|
|
61
|
+
|
|
62
|
+
configure_logging(
|
|
63
|
+
env="production",
|
|
64
|
+
log_level="INFO",
|
|
65
|
+
get_trace_id_fn=get_trace_id, # inject trace_id from ContextVar
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## OTel tracing
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from nodus_observability import init_otel, get_tracer
|
|
73
|
+
|
|
74
|
+
init_otel(service_name="my-service") # reads OTEL_EXPORTER_OTLP_ENDPOINT
|
|
75
|
+
tracer = get_tracer("my-module")
|
|
76
|
+
|
|
77
|
+
with tracer.start_as_current_span("my-operation") as span:
|
|
78
|
+
span.set_status("ok")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Prometheus metrics
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from nodus_observability import create_registry, Counter
|
|
85
|
+
|
|
86
|
+
REGISTRY = create_registry() # never use the default global registry
|
|
87
|
+
|
|
88
|
+
requests_total = Counter(
|
|
89
|
+
"myapp_requests_total",
|
|
90
|
+
"Total requests",
|
|
91
|
+
["method", "status"],
|
|
92
|
+
registry=REGISTRY,
|
|
93
|
+
)
|
|
94
|
+
requests_total.labels(method="GET", status="200").inc()
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Extracted from
|
|
98
|
+
|
|
99
|
+
`AINDY/platform_layer/trace_context.py`, `otel.py`, `metrics.py`, and `log_config.py` in the A.I.N.D.Y. runtime.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
nodus_observability/__init__.py
|
|
5
|
+
nodus_observability/context.py
|
|
6
|
+
nodus_observability/logging.py
|
|
7
|
+
nodus_observability/metrics.py
|
|
8
|
+
nodus_observability/otel.py
|
|
9
|
+
nodus_observability.egg-info/PKG-INFO
|
|
10
|
+
nodus_observability.egg-info/SOURCES.txt
|
|
11
|
+
nodus_observability.egg-info/dependency_links.txt
|
|
12
|
+
nodus_observability.egg-info/requires.txt
|
|
13
|
+
nodus_observability.egg-info/top_level.txt
|
|
14
|
+
tests/test_context.py
|
|
15
|
+
tests/test_logging.py
|
|
16
|
+
tests/test_otel.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
|
|
2
|
+
[all]
|
|
3
|
+
nodus-observability[logging,metrics,otel]
|
|
4
|
+
|
|
5
|
+
[dev]
|
|
6
|
+
pytest>=8.0
|
|
7
|
+
python-json-logger>=2.0.0
|
|
8
|
+
|
|
9
|
+
[logging]
|
|
10
|
+
python-json-logger>=2.0.0
|
|
11
|
+
|
|
12
|
+
[metrics]
|
|
13
|
+
prometheus-client>=0.10.0
|
|
14
|
+
|
|
15
|
+
[otel]
|
|
16
|
+
opentelemetry-api>=1.0.0
|
|
17
|
+
opentelemetry-sdk>=1.0.0
|
|
18
|
+
opentelemetry-exporter-otlp-proto-grpc>=1.0.0
|
|
19
|
+
opentelemetry-instrumentation-fastapi>=0.60b0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nodus_observability
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nodus-observability"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "OTel tracing, Prometheus metrics, structured JSON logging, and trace ContextVars"
|
|
9
|
+
authors = [{ name = "Shawn Knight" }]
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
keywords = ["observability", "opentelemetry", "prometheus", "logging", "tracing", "nodus"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
logging = ["python-json-logger>=2.0.0"]
|
|
25
|
+
otel = ["opentelemetry-api>=1.0.0", "opentelemetry-sdk>=1.0.0",
|
|
26
|
+
"opentelemetry-exporter-otlp-proto-grpc>=1.0.0",
|
|
27
|
+
"opentelemetry-instrumentation-fastapi>=0.60b0"]
|
|
28
|
+
metrics = ["prometheus-client>=0.10.0"]
|
|
29
|
+
all = ["nodus-observability[logging,otel,metrics]"]
|
|
30
|
+
dev = ["pytest>=8.0", "python-json-logger>=2.0.0"]
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://github.com/Masterplanner25/nodus-observability"
|
|
34
|
+
Repository = "https://github.com/Masterplanner25/nodus-observability"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["."]
|
|
38
|
+
include = ["nodus_observability*"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from nodus_observability import (
|
|
6
|
+
ensure_trace_id,
|
|
7
|
+
get_parent_event_id,
|
|
8
|
+
get_trace_id,
|
|
9
|
+
is_pipeline_active,
|
|
10
|
+
reset_parent_event_id,
|
|
11
|
+
reset_pipeline_active,
|
|
12
|
+
reset_trace_id,
|
|
13
|
+
set_parent_event_id,
|
|
14
|
+
set_pipeline_active,
|
|
15
|
+
set_trace_id,
|
|
16
|
+
)
|
|
17
|
+
from nodus_observability.context import (
|
|
18
|
+
get_current_execution_context,
|
|
19
|
+
get_current_request,
|
|
20
|
+
reset_current_execution_context,
|
|
21
|
+
reset_current_request,
|
|
22
|
+
set_current_execution_context,
|
|
23
|
+
set_current_request,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── Trace ID ──────────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def test_get_trace_id_default_is_none():
|
|
30
|
+
# Reset to default state via a fresh set/reset cycle
|
|
31
|
+
tok = set_trace_id("-")
|
|
32
|
+
assert get_trace_id() is None
|
|
33
|
+
reset_trace_id(tok)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_set_and_get_trace_id():
|
|
37
|
+
tok = set_trace_id("trace-abc")
|
|
38
|
+
assert get_trace_id() == "trace-abc"
|
|
39
|
+
reset_trace_id(tok)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_reset_trace_id_restores_previous():
|
|
43
|
+
tok1 = set_trace_id("first")
|
|
44
|
+
tok2 = set_trace_id("second")
|
|
45
|
+
assert get_trace_id() == "second"
|
|
46
|
+
reset_trace_id(tok2)
|
|
47
|
+
assert get_trace_id() == "first"
|
|
48
|
+
reset_trace_id(tok1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_ensure_trace_id_generates_uuid_when_empty():
|
|
52
|
+
tok = set_trace_id("-")
|
|
53
|
+
result = ensure_trace_id()
|
|
54
|
+
assert result # non-empty
|
|
55
|
+
uuid.UUID(result) # valid UUID
|
|
56
|
+
reset_trace_id(tok)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_ensure_trace_id_returns_existing():
|
|
60
|
+
tok = set_trace_id("existing-id")
|
|
61
|
+
result = ensure_trace_id()
|
|
62
|
+
assert result == "existing-id"
|
|
63
|
+
reset_trace_id(tok)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_get_trace_id_with_default():
|
|
67
|
+
tok = set_trace_id("-")
|
|
68
|
+
assert get_trace_id(default="fallback") == "fallback"
|
|
69
|
+
reset_trace_id(tok)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── Parent event ID ───────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def test_get_parent_event_id_default_is_none():
|
|
75
|
+
tok = set_parent_event_id(None)
|
|
76
|
+
assert get_parent_event_id() is None
|
|
77
|
+
reset_parent_event_id(tok)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_set_and_get_parent_event_id():
|
|
81
|
+
tok = set_parent_event_id("event-123")
|
|
82
|
+
assert get_parent_event_id() == "event-123"
|
|
83
|
+
reset_parent_event_id(tok)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_set_parent_event_id_none_clears():
|
|
87
|
+
tok1 = set_parent_event_id("event-abc")
|
|
88
|
+
tok2 = set_parent_event_id(None)
|
|
89
|
+
assert get_parent_event_id() is None
|
|
90
|
+
reset_parent_event_id(tok2)
|
|
91
|
+
reset_parent_event_id(tok1)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ── Pipeline active flag ──────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
def test_pipeline_active_default_false():
|
|
97
|
+
assert is_pipeline_active() is False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_set_pipeline_active():
|
|
101
|
+
tok = set_pipeline_active(True)
|
|
102
|
+
assert is_pipeline_active() is True
|
|
103
|
+
reset_pipeline_active(tok)
|
|
104
|
+
assert is_pipeline_active() is False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ── Current request ───────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
def test_get_current_request_default_none():
|
|
110
|
+
assert get_current_request() is None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_set_and_get_current_request():
|
|
114
|
+
request = object()
|
|
115
|
+
tok = set_current_request(request)
|
|
116
|
+
assert get_current_request() is request
|
|
117
|
+
reset_current_request(tok)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── Execution context ─────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def test_get_execution_context_default_none():
|
|
123
|
+
assert get_current_execution_context() is None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_set_and_get_execution_context():
|
|
127
|
+
ctx = {"user_id": "u1", "run_id": "r1"}
|
|
128
|
+
tok = set_current_execution_context(ctx)
|
|
129
|
+
assert get_current_execution_context() is ctx
|
|
130
|
+
reset_current_execution_context(tok)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from nodus_observability import configure_logging, get_trace_id, set_trace_id, reset_trace_id
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_configure_logging_runs_without_error():
|
|
9
|
+
configure_logging(env="development", force=True)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_configure_logging_sets_log_level():
|
|
13
|
+
configure_logging(env="development", log_level="DEBUG", force=True)
|
|
14
|
+
assert logging.getLogger().level == logging.DEBUG
|
|
15
|
+
configure_logging(env="development", log_level="INFO", force=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_configure_logging_with_trace_id_fn():
|
|
19
|
+
tok = set_trace_id("test-trace-123")
|
|
20
|
+
configure_logging(
|
|
21
|
+
env="development",
|
|
22
|
+
force=True,
|
|
23
|
+
get_trace_id_fn=get_trace_id,
|
|
24
|
+
)
|
|
25
|
+
logger = logging.getLogger("test_logging")
|
|
26
|
+
# Verify the filter injects trace_id — exercise the filter manually
|
|
27
|
+
root = logging.getLogger()
|
|
28
|
+
handler = next(
|
|
29
|
+
(h for h in root.handlers if hasattr(h, "_nodus_structured_logging_handler")),
|
|
30
|
+
None,
|
|
31
|
+
)
|
|
32
|
+
if handler:
|
|
33
|
+
record = logging.LogRecord(
|
|
34
|
+
name="test", level=logging.INFO, pathname="", lineno=0,
|
|
35
|
+
msg="test", args=(), exc_info=None,
|
|
36
|
+
)
|
|
37
|
+
for f in handler.filters:
|
|
38
|
+
f.filter(record)
|
|
39
|
+
assert record.trace_id == "test-trace-123"
|
|
40
|
+
reset_trace_id(tok)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_configure_logging_no_callbacks_does_not_raise():
|
|
44
|
+
configure_logging(env="development", force=True)
|
|
45
|
+
root = logging.getLogger()
|
|
46
|
+
handler = next(
|
|
47
|
+
(h for h in root.handlers if hasattr(h, "_nodus_structured_logging_handler")),
|
|
48
|
+
None,
|
|
49
|
+
)
|
|
50
|
+
if handler:
|
|
51
|
+
record = logging.LogRecord(
|
|
52
|
+
name="test", level=logging.INFO, pathname="", lineno=0,
|
|
53
|
+
msg="test", args=(), exc_info=None,
|
|
54
|
+
)
|
|
55
|
+
for f in handler.filters:
|
|
56
|
+
f.filter(record)
|
|
57
|
+
assert record.trace_id == ""
|
|
58
|
+
assert record.user_id == ""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_configure_logging_idempotent():
|
|
62
|
+
configure_logging(env="development", force=True)
|
|
63
|
+
configure_logging(env="development") # should not add duplicate handlers
|
|
64
|
+
root = logging.getLogger()
|
|
65
|
+
nodus_handlers = [
|
|
66
|
+
h for h in root.handlers if hasattr(h, "_nodus_structured_logging_handler")
|
|
67
|
+
]
|
|
68
|
+
assert len(nodus_handlers) == 1
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from nodus_observability import get_tracer, init_otel, span_context_from_trace_id
|
|
4
|
+
from nodus_observability.otel import _NoopTracer, _initialized
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_init_otel_is_idempotent():
|
|
8
|
+
# May already be initialized from a previous test run in the process
|
|
9
|
+
init_otel(service_name="test")
|
|
10
|
+
init_otel(service_name="test") # second call must not raise
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def test_get_tracer_returns_something():
|
|
14
|
+
tracer = get_tracer("test-tracer")
|
|
15
|
+
assert tracer is not None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_noop_tracer_context_manager():
|
|
19
|
+
tracer = _NoopTracer()
|
|
20
|
+
with tracer.start_as_current_span("test-span") as span:
|
|
21
|
+
span.set_status("ok")
|
|
22
|
+
span.record_exception(ValueError("test"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_span_context_from_trace_id_valid_hex():
|
|
26
|
+
hex_id = "a" * 32
|
|
27
|
+
# Returns a SpanContext when OTEL is available, None otherwise — both are valid
|
|
28
|
+
result = span_context_from_trace_id(hex_id)
|
|
29
|
+
# Just verify it doesn't raise
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_span_context_from_trace_id_none():
|
|
33
|
+
result = span_context_from_trace_id(None)
|
|
34
|
+
assert result is None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_span_context_from_trace_id_empty():
|
|
38
|
+
result = span_context_from_trace_id("")
|
|
39
|
+
assert result is None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_span_context_from_uuid_style():
|
|
43
|
+
import uuid
|
|
44
|
+
uid = str(uuid.uuid4())
|
|
45
|
+
result = span_context_from_trace_id(uid)
|
|
46
|
+
# Should not raise regardless of OTEL availability
|