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.
@@ -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,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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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