hexastack-otel 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hexastack-otel might be problematic. Click here for more details.

@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.3
2
+ Name: hexastack-otel
3
+ Version: 0.0.0
4
+ Summary: OpenTelemetry distributed tracing, OTLP exporter integration, and CQRS tracing middleware for Hexastack
5
+ Author: Richard West
6
+ Author-email: Richard West <dopplereffect.us@gmail.com>
7
+ Requires-Dist: hexastack-core
8
+ Requires-Dist: hexastack-cqrs
9
+ Requires-Dist: opentelemetry-api>=1.27.0
10
+ Requires-Dist: opentelemetry-sdk>=1.27.0
11
+ Requires-Dist: pydantic>=2.10.0
12
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.27.0 ; extra == 'otlp'
13
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0 ; extra == 'otlp'
14
+ Requires-Python: >=3.13
15
+ Provides-Extra: otlp
16
+ Description-Content-Type: text/markdown
17
+
18
+ # hexastack-otel
19
+
20
+ **Vendor-agnostic OpenTelemetry distributed tracing and CQRS telemetry middleware for Hexastack.**
21
+
22
+ Part of the [Hexastack Framework](https://github.com/TheTrueSCU/hexastack).
23
+
24
+ ---
25
+
26
+ ## 1. Architectural Overview
27
+
28
+ `hexastack-otel` provides a pure hexagonal port interface ([`TracingPort`](file:///home/rjdw/Projects/hexastack/packages/hexastack_otel/src/hexastack_otel/ports/tracing.py)) backed by the official **OpenTelemetry Python SDK**.
29
+
30
+ It enables 100% vendor-agnostic distributed tracing, automatically linking correlation IDs and creating spans across CQRS Command/Query executions, HTTP endpoints, and gRPC RPCs.
31
+
32
+ ```mermaid
33
+ graph TD
34
+ CQRS["CQRS Command / Query Dispatch"]
35
+ MW["TracingMiddleware (order=12)"]
36
+ PORT["TracingPort"]
37
+ OTEL["OtelTracingAdapter\n(OpenTelemetry SDK)"]
38
+ MEM["InMemoryTracingAdapter\n(Test Isolation)"]
39
+ OTLP["OTLP Exporter\n(gRPC / HTTP)"]
40
+ COLLECTOR["OTel Collector / Jaeger / Grafana Tempo / Sift"]
41
+
42
+ CQRS --> MW
43
+ MW --> PORT
44
+ PORT -.-> OTEL
45
+ PORT -.-> MEM
46
+ OTEL --> OTLP
47
+ OTLP --> COLLECTOR
48
+ ```
49
+
50
+ ---
51
+
52
+ ## 2. Quickstart
53
+
54
+ ### Installation
55
+
56
+ ```bash
57
+ # Core OpenTelemetry SDK & TracingPort
58
+ pip install hexastack-otel
59
+
60
+ # With OTLP Protobuf Exporters (gRPC & HTTP)
61
+ pip install hexastack-otel[otlp]
62
+ ```
63
+
64
+ ### Configuration (`hexastack.toml` or `pyproject.toml`)
65
+
66
+ ```toml
67
+ [hexastack.otel]
68
+ service_name = "order-service"
69
+ endpoint = "http://localhost:4317"
70
+ exporter = "otlp_grpc" # "memory", "console", "otlp_grpc", "otlp_http"
71
+ sample_rate = 1.0
72
+ enabled = true
73
+ ```
74
+
75
+ ---
76
+
77
+ ## 3. Automatic CQRS Tracing & Dynamic Feature Flagging
78
+
79
+ The [`TracingMiddleware`](file:///home/rjdw/Projects/hexastack/packages/hexastack_otel/src/hexastack_otel/infra/middleware.py) automatically wraps every dispatched command or query in a scoped span:
80
+
81
+ - **Dynamic Feature Flag Control**: Evaluates `features.otel.tracing` via `FeatureFlagPort` dynamically per-message, enabling zero-downtime activation/deactivation of telemetry spans.
82
+ - **Span Name**: `cqrs.CreateOrderCommand`
83
+ - **Attributes**:
84
+ - `message.name`: `CreateOrderCommand`
85
+ - `message.type`: `command`
86
+ - `correlation.id`: `0724ec78-f952-4467...`
87
+ - `tenant.id`: `tenant-alpha`
88
+ - `user.id`: `usr_123`
89
+ - **Error Capture**: Automatically catches unhandled exceptions, records them as span exception events, and sets span status to `ERROR`.
@@ -0,0 +1,72 @@
1
+ # hexastack-otel
2
+
3
+ **Vendor-agnostic OpenTelemetry distributed tracing and CQRS telemetry middleware for Hexastack.**
4
+
5
+ Part of the [Hexastack Framework](https://github.com/TheTrueSCU/hexastack).
6
+
7
+ ---
8
+
9
+ ## 1. Architectural Overview
10
+
11
+ `hexastack-otel` provides a pure hexagonal port interface ([`TracingPort`](file:///home/rjdw/Projects/hexastack/packages/hexastack_otel/src/hexastack_otel/ports/tracing.py)) backed by the official **OpenTelemetry Python SDK**.
12
+
13
+ It enables 100% vendor-agnostic distributed tracing, automatically linking correlation IDs and creating spans across CQRS Command/Query executions, HTTP endpoints, and gRPC RPCs.
14
+
15
+ ```mermaid
16
+ graph TD
17
+ CQRS["CQRS Command / Query Dispatch"]
18
+ MW["TracingMiddleware (order=12)"]
19
+ PORT["TracingPort"]
20
+ OTEL["OtelTracingAdapter\n(OpenTelemetry SDK)"]
21
+ MEM["InMemoryTracingAdapter\n(Test Isolation)"]
22
+ OTLP["OTLP Exporter\n(gRPC / HTTP)"]
23
+ COLLECTOR["OTel Collector / Jaeger / Grafana Tempo / Sift"]
24
+
25
+ CQRS --> MW
26
+ MW --> PORT
27
+ PORT -.-> OTEL
28
+ PORT -.-> MEM
29
+ OTEL --> OTLP
30
+ OTLP --> COLLECTOR
31
+ ```
32
+
33
+ ---
34
+
35
+ ## 2. Quickstart
36
+
37
+ ### Installation
38
+
39
+ ```bash
40
+ # Core OpenTelemetry SDK & TracingPort
41
+ pip install hexastack-otel
42
+
43
+ # With OTLP Protobuf Exporters (gRPC & HTTP)
44
+ pip install hexastack-otel[otlp]
45
+ ```
46
+
47
+ ### Configuration (`hexastack.toml` or `pyproject.toml`)
48
+
49
+ ```toml
50
+ [hexastack.otel]
51
+ service_name = "order-service"
52
+ endpoint = "http://localhost:4317"
53
+ exporter = "otlp_grpc" # "memory", "console", "otlp_grpc", "otlp_http"
54
+ sample_rate = 1.0
55
+ enabled = true
56
+ ```
57
+
58
+ ---
59
+
60
+ ## 3. Automatic CQRS Tracing & Dynamic Feature Flagging
61
+
62
+ The [`TracingMiddleware`](file:///home/rjdw/Projects/hexastack/packages/hexastack_otel/src/hexastack_otel/infra/middleware.py) automatically wraps every dispatched command or query in a scoped span:
63
+
64
+ - **Dynamic Feature Flag Control**: Evaluates `features.otel.tracing` via `FeatureFlagPort` dynamically per-message, enabling zero-downtime activation/deactivation of telemetry spans.
65
+ - **Span Name**: `cqrs.CreateOrderCommand`
66
+ - **Attributes**:
67
+ - `message.name`: `CreateOrderCommand`
68
+ - `message.type`: `command`
69
+ - `correlation.id`: `0724ec78-f952-4467...`
70
+ - `tenant.id`: `tenant-alpha`
71
+ - `user.id`: `usr_123`
72
+ - **Error Capture**: Automatically catches unhandled exceptions, records them as span exception events, and sets span status to `ERROR`.
@@ -0,0 +1,68 @@
1
+ [project]
2
+ name = "hexastack-otel"
3
+ version = "0.0.0"
4
+ description = "OpenTelemetry distributed tracing, OTLP exporter integration, and CQRS tracing middleware for Hexastack"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ dependencies = [
8
+ "hexastack-core",
9
+ "hexastack-cqrs",
10
+ "opentelemetry-api>=1.27.0",
11
+ "opentelemetry-sdk>=1.27.0",
12
+ "pydantic>=2.10.0",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Richard West"
17
+ email = "dopplereffect.us@gmail.com"
18
+
19
+ [project.optional-dependencies]
20
+ otlp = [
21
+ "opentelemetry-exporter-otlp-proto-grpc>=1.27.0",
22
+ "opentelemetry-exporter-otlp-proto-http>=1.27.0",
23
+ ]
24
+
25
+ [project.entry-points."hexastack.bootstrappers"]
26
+ otel = "hexastack_otel.infra.bootstrap:OtelBootstrapper"
27
+
28
+ [build-system]
29
+ requires = ["uv_build>=0.12.3,<0.13.0"]
30
+ build-backend = "uv_build"
31
+
32
+ [tool.uv.sources.hexastack-core]
33
+ workspace = true
34
+
35
+ [tool.uv.sources.hexastack-cqrs]
36
+ workspace = true
37
+
38
+ [tool.importlinter]
39
+ root_packages = ["hexastack_otel"]
40
+
41
+ [[tool.importlinter.contracts]]
42
+ name = "Hexagonal architecture layer hierarchy"
43
+ type = "layers"
44
+ containers = ["hexastack_otel"]
45
+ layers = [
46
+ "adapters",
47
+ "ports",
48
+ "domain",
49
+ ]
50
+
51
+ [[tool.importlinter.contracts]]
52
+ name = "Forbidden imports for domain"
53
+ type = "forbidden"
54
+ source_modules = ["hexastack_otel.domain"]
55
+ forbidden_modules = [
56
+ "hexastack_otel.ports",
57
+ "hexastack_otel.adapters",
58
+ "hexastack_otel.infra",
59
+ ]
60
+
61
+ [[tool.importlinter.contracts]]
62
+ name = "Forbidden imports for ports"
63
+ type = "forbidden"
64
+ source_modules = ["hexastack_otel.ports"]
65
+ forbidden_modules = [
66
+ "hexastack_otel.adapters",
67
+ "hexastack_otel.infra",
68
+ ]
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "hexastack-otel"
3
+ version = "0.0.0"
4
+ description = "OpenTelemetry distributed tracing, OTLP exporter integration, and CQRS tracing middleware for Hexastack"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Richard West", email = "dopplereffect.us@gmail.com" }
8
+ ]
9
+ requires-python = ">=3.13"
10
+ dependencies = [
11
+ "hexastack-core",
12
+ "hexastack-cqrs",
13
+ "opentelemetry-api>=1.27.0",
14
+ "opentelemetry-sdk>=1.27.0",
15
+ "pydantic>=2.10.0",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ otlp = [
20
+ "opentelemetry-exporter-otlp-proto-grpc>=1.27.0",
21
+ "opentelemetry-exporter-otlp-proto-http>=1.27.0",
22
+ ]
23
+
24
+ [project.entry-points."hexastack.bootstrappers"]
25
+ otel = "hexastack_otel.infra.bootstrap:OtelBootstrapper"
26
+
27
+ [build-system]
28
+ requires = ["uv_build>=0.12.3,<0.13.0"]
29
+ build-backend = "uv_build"
30
+
31
+ [tool.uv.sources]
32
+ hexastack-core = { workspace = true }
33
+ hexastack-cqrs = { workspace = true }
34
+
35
+ [tool.importlinter]
36
+ root_packages = ["hexastack_otel"]
37
+
38
+ [[tool.importlinter.contracts]]
39
+ name = "Hexagonal architecture layer hierarchy"
40
+ type = "layers"
41
+ containers = ["hexastack_otel"]
42
+ layers = [
43
+ "adapters",
44
+ "ports",
45
+ "domain",
46
+ ]
47
+
48
+ [[tool.importlinter.contracts]]
49
+ name = "Forbidden imports for domain"
50
+ type = "forbidden"
51
+ source_modules = ["hexastack_otel.domain"]
52
+ forbidden_modules = [
53
+ "hexastack_otel.ports",
54
+ "hexastack_otel.adapters",
55
+ "hexastack_otel.infra",
56
+ ]
57
+
58
+ [[tool.importlinter.contracts]]
59
+ name = "Forbidden imports for ports"
60
+ type = "forbidden"
61
+ source_modules = ["hexastack_otel.ports"]
62
+ forbidden_modules = [
63
+ "hexastack_otel.adapters",
64
+ "hexastack_otel.infra",
65
+ ]
@@ -0,0 +1,8 @@
1
+ from hexastack_otel import adapters, domain, infra, ports
2
+
3
+ __all__ = [
4
+ "adapters",
5
+ "domain",
6
+ "infra",
7
+ "ports",
8
+ ]
@@ -0,0 +1,13 @@
1
+ from hexastack_otel.adapters.tracing import (
2
+ InMemorySpan,
3
+ InMemoryTracingAdapter,
4
+ OtelSpan,
5
+ OtelTracingAdapter,
6
+ )
7
+
8
+ __all__ = [
9
+ "InMemorySpan",
10
+ "InMemoryTracingAdapter",
11
+ "OtelSpan",
12
+ "OtelTracingAdapter",
13
+ ]
@@ -0,0 +1,15 @@
1
+ from hexastack_otel.adapters.tracing.in_memory import (
2
+ InMemorySpan,
3
+ InMemoryTracingAdapter,
4
+ )
5
+ from hexastack_otel.adapters.tracing.otel import (
6
+ OtelSpan,
7
+ OtelTracingAdapter,
8
+ )
9
+
10
+ __all__ = [
11
+ "InMemorySpan",
12
+ "InMemoryTracingAdapter",
13
+ "OtelSpan",
14
+ "OtelTracingAdapter",
15
+ ]
@@ -0,0 +1,155 @@
1
+ import time
2
+ import uuid
3
+ from collections.abc import Iterator
4
+ from contextlib import contextmanager
5
+ from contextvars import ContextVar
6
+ from typing import Any
7
+
8
+ from hexastack_otel.domain.context import SpanContext
9
+ from hexastack_otel.ports.tracing import SpanPort, TracingPort
10
+
11
+ _current_in_memory_span: ContextVar["InMemorySpan | None"] = ContextVar(
12
+ "current_in_memory_span", default=None
13
+ )
14
+
15
+
16
+ class InMemorySpan(SpanPort):
17
+ """In-memory telemetry span recording all events, status, and attributes.
18
+
19
+ Notes/Architectural Intent:
20
+ Used for isolated unit and property testing without requiring OTel SDK
21
+ runtime or live network collectors.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ name: str,
27
+ attributes: dict[str, Any] | None = None,
28
+ context: SpanContext | None = None,
29
+ parent_context: SpanContext | None = None,
30
+ ) -> None:
31
+ self.name = name
32
+ self.attributes: dict[str, Any] = dict(attributes or {})
33
+ self.context = context or SpanContext(
34
+ trace_id=uuid.uuid4().hex,
35
+ span_id=uuid.uuid4().hex[:16],
36
+ )
37
+ self.parent_context = parent_context
38
+ self.status = "UNSET"
39
+ self.status_description: str | None = None
40
+ self.exceptions: list[BaseException] = []
41
+ self.start_time: float = time.time()
42
+ self.end_time: float | None = None
43
+ self.is_ended: bool = False
44
+
45
+ def end(self) -> None:
46
+ """End the span recording."""
47
+ if not self.is_ended:
48
+ self.end_time = time.time()
49
+ self.is_ended = True
50
+
51
+ def record_exception(self, exception: BaseException) -> None:
52
+ """Record an exception on the in-memory span."""
53
+ self.exceptions.append(exception)
54
+ self.set_status("ERROR", str(exception))
55
+
56
+ def set_attribute(self, key: str, value: Any) -> None:
57
+ """Set a single key-value attribute on the span."""
58
+ self.attributes[key] = value
59
+
60
+ def set_attributes(self, attributes: dict[str, Any]) -> None:
61
+ """Set multiple attributes on the span."""
62
+ self.attributes.update(attributes)
63
+
64
+ def set_status(self, status: str, description: str | None = None) -> None:
65
+ """Set the span status ('OK', 'ERROR', 'UNSET')."""
66
+ self.status = status
67
+ self.status_description = description
68
+
69
+
70
+ class InMemoryTracingAdapter(TracingPort):
71
+ """In-memory implementation of TracingPort for test isolation and assertions.
72
+
73
+ Notes/Architectural Intent:
74
+ Collects all finished spans in a local list, enabling assertions on span
75
+ counts, tags, and exception capture in test suites.
76
+ """
77
+
78
+ def __init__(self) -> None:
79
+ self.finished_spans: list[InMemorySpan] = []
80
+
81
+ def clear(self) -> None:
82
+ """Clear recorded finished spans."""
83
+ self.finished_spans.clear()
84
+
85
+ def extract_context(self, carrier: dict[str, str]) -> SpanContext | None:
86
+ traceparent = carrier.get("traceparent") or carrier.get("TRACEPARENT")
87
+ if not traceparent:
88
+ return None
89
+ parts = traceparent.split("-")
90
+ if len(parts) >= 4:
91
+ return SpanContext(trace_id=parts[1], span_id=parts[2])
92
+ return None
93
+
94
+ def get_current_span(self) -> InMemorySpan | None:
95
+ return _current_in_memory_span.get()
96
+
97
+ def get_spans_by_name(self, name: str) -> list[InMemorySpan]:
98
+ """Find recorded spans matching name."""
99
+ return [s for s in self.finished_spans if s.name == name]
100
+
101
+ def inject_context(self, carrier: dict[str, str]) -> None:
102
+ current = self.get_current_span()
103
+ if current:
104
+ ctx = current.context
105
+ carrier["traceparent"] = f"00-{ctx.trace_id}-{ctx.span_id}-01"
106
+
107
+ def start_span(
108
+ self,
109
+ name: str,
110
+ *,
111
+ attributes: dict[str, Any] | None = None,
112
+ parent_context: SpanContext | None = None,
113
+ ) -> InMemorySpan:
114
+ parent = parent_context
115
+ current = self.get_current_span()
116
+ if parent is None and current is not None and isinstance(current, InMemorySpan):
117
+ parent = current.context
118
+
119
+ trace_id = parent.trace_id if parent else uuid.uuid4().hex
120
+ span_ctx = SpanContext(
121
+ trace_id=trace_id,
122
+ span_id=uuid.uuid4().hex[:16],
123
+ )
124
+
125
+ return InMemorySpan(
126
+ name=name,
127
+ attributes=attributes,
128
+ context=span_ctx,
129
+ parent_context=parent,
130
+ )
131
+
132
+ @contextmanager
133
+ def trace_scope(
134
+ self,
135
+ name: str,
136
+ *,
137
+ attributes: dict[str, Any] | None = None,
138
+ ) -> Iterator[InMemorySpan]:
139
+ span = self.start_span(name, attributes=attributes)
140
+ token = _current_in_memory_span.set(span)
141
+ try:
142
+ yield span
143
+ except BaseException as exc:
144
+ span.record_exception(exc)
145
+ raise
146
+ finally:
147
+ span.end()
148
+ self.finished_spans.append(span)
149
+ _current_in_memory_span.reset(token)
150
+
151
+
152
+ __all__ = [
153
+ "InMemorySpan",
154
+ "InMemoryTracingAdapter",
155
+ ]
@@ -0,0 +1,138 @@
1
+ from collections.abc import Iterator
2
+ from contextlib import contextmanager
3
+ from typing import Any
4
+
5
+ from opentelemetry import trace
6
+ from opentelemetry.sdk.resources import Resource
7
+ from opentelemetry.sdk.trace import TracerProvider
8
+ from opentelemetry.sdk.trace.export import (
9
+ SimpleSpanProcessor,
10
+ SpanExporter,
11
+ )
12
+ from opentelemetry.trace import (
13
+ NonRecordingSpan,
14
+ StatusCode,
15
+ TraceFlags,
16
+ TraceState,
17
+ )
18
+ from opentelemetry.trace import (
19
+ Span as OtelRawSpan,
20
+ )
21
+ from opentelemetry.trace.propagation.tracecontext import (
22
+ TraceContextTextMapPropagator,
23
+ )
24
+
25
+ from hexastack_otel.domain.context import SpanContext
26
+ from hexastack_otel.ports.tracing import SpanPort, TracingPort
27
+
28
+
29
+ class OtelSpan(SpanPort):
30
+ """Concrete wrapper around an OpenTelemetry SDK Span."""
31
+
32
+ def __init__(self, raw_span: OtelRawSpan) -> None:
33
+ self._span = raw_span
34
+
35
+ def end(self) -> None:
36
+ self._span.end()
37
+
38
+ def record_exception(self, exception: BaseException) -> None:
39
+ self._span.record_exception(exception)
40
+ self._span.set_status(StatusCode.ERROR, str(exception))
41
+
42
+ def set_attribute(self, key: str, value: Any) -> None:
43
+ self._span.set_attribute(key, value)
44
+
45
+ def set_attributes(self, attributes: dict[str, Any]) -> None:
46
+ self._span.set_attributes(attributes)
47
+
48
+ def set_status(self, status: str, description: str | None = None) -> None:
49
+ code = StatusCode.OK if status.upper() == "OK" else StatusCode.ERROR
50
+ self._span.set_status(code, description)
51
+
52
+
53
+ class OtelTracingAdapter(TracingPort):
54
+ """Production OpenTelemetry SDK adapter supporting OTLP, Console, and custom exporters.
55
+
56
+ Notes/Architectural Intent:
57
+ Implements TracingPort using opentelemetry-sdk. Supports W3C TraceContext
58
+ header injection and extraction across distributed service boundaries.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ service_name: str = "hexastack-app",
64
+ tracer_provider: TracerProvider | None = None,
65
+ exporter: SpanExporter | None = None,
66
+ ) -> None:
67
+ self._service_name = service_name
68
+ self._propagator = TraceContextTextMapPropagator()
69
+
70
+ if tracer_provider is not None:
71
+ self._provider = tracer_provider
72
+ else:
73
+ resource = Resource.create({"service.name": service_name})
74
+ self._provider = TracerProvider(resource=resource)
75
+ if exporter is not None:
76
+ self._provider.add_span_processor(SimpleSpanProcessor(exporter))
77
+
78
+ self._tracer = self._provider.get_tracer("hexastack", "0.1.0")
79
+
80
+ def extract_context(self, carrier: dict[str, str]) -> SpanContext | None:
81
+ ctx = self._propagator.extract(carrier)
82
+ current = trace.get_current_span(ctx)
83
+ span_ctx = current.get_span_context()
84
+ if span_ctx.is_valid:
85
+ return SpanContext(
86
+ trace_id=f"{span_ctx.trace_id:032x}",
87
+ span_id=f"{span_ctx.span_id:016x}",
88
+ trace_flags=int(span_ctx.trace_flags),
89
+ )
90
+ return None
91
+
92
+ def get_current_span(self) -> OtelSpan | None:
93
+ raw = trace.get_current_span()
94
+ if raw and raw.is_recording():
95
+ return OtelSpan(raw)
96
+ return None
97
+
98
+ def inject_context(self, carrier: dict[str, str]) -> None:
99
+ self._propagator.inject(carrier)
100
+
101
+ def start_span(
102
+ self,
103
+ name: str,
104
+ *,
105
+ attributes: dict[str, Any] | None = None,
106
+ parent_context: SpanContext | None = None,
107
+ ) -> OtelSpan:
108
+ context = None
109
+ if parent_context:
110
+ otel_span_context = trace.SpanContext(
111
+ trace_id=int(parent_context.trace_id, 16),
112
+ span_id=int(parent_context.span_id, 16),
113
+ is_remote=True,
114
+ trace_flags=TraceFlags(parent_context.trace_flags),
115
+ trace_state=TraceState.from_header([parent_context.trace_state])
116
+ if parent_context.trace_state
117
+ else None,
118
+ )
119
+ context = trace.set_span_in_context(NonRecordingSpan(otel_span_context))
120
+
121
+ raw = self._tracer.start_span(name, attributes=attributes, context=context)
122
+ return OtelSpan(raw)
123
+
124
+ @contextmanager
125
+ def trace_scope(
126
+ self,
127
+ name: str,
128
+ *,
129
+ attributes: dict[str, Any] | None = None,
130
+ ) -> Iterator[OtelSpan]:
131
+ with self._tracer.start_as_current_span(name, attributes=attributes) as raw:
132
+ yield OtelSpan(raw)
133
+
134
+
135
+ __all__ = [
136
+ "OtelSpan",
137
+ "OtelTracingAdapter",
138
+ ]
@@ -0,0 +1,11 @@
1
+ from hexastack_otel.domain.context import SpanContext
2
+ from hexastack_otel.domain.exceptions import (
3
+ OtelError,
4
+ TracingConfigurationError,
5
+ )
6
+
7
+ __all__ = [
8
+ "OtelError",
9
+ "SpanContext",
10
+ "TracingConfigurationError",
11
+ ]
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class SpanContext:
6
+ """Immutable representation of a distributed trace context.
7
+
8
+ Notes/Architectural Intent:
9
+ Standardized representation of W3C TraceContext headers (traceparent)
10
+ for cross-service context propagation. Represents pure domain context.
11
+ """
12
+
13
+ trace_id: str
14
+ span_id: str
15
+ trace_flags: int = 1
16
+ trace_state: str = ""
17
+
18
+
19
+ __all__ = [
20
+ "SpanContext",
21
+ ]
@@ -0,0 +1,20 @@
1
+ from hexastack_core.domain.exceptions import HexastackError
2
+
3
+
4
+ class OtelError(HexastackError):
5
+ """Base exception for all OpenTelemetry telemetry and tracing errors.
6
+
7
+ Notes/Architectural Intent:
8
+ Extends HexastackError to allow telemetry failures to be caught uniformly
9
+ without leaking OpenTelemetry SDK internals into the core domain.
10
+ """
11
+
12
+
13
+ class TracingConfigurationError(OtelError):
14
+ """Exception raised when OpenTelemetry tracer initialization or endpoint config fails."""
15
+
16
+
17
+ __all__ = [
18
+ "OtelError",
19
+ "TracingConfigurationError",
20
+ ]
@@ -0,0 +1,10 @@
1
+ from hexastack_otel.infra.bootstrap import OtelBootstrapper
2
+ from hexastack_otel.infra.config import HexastackOtelConfig, register_otel_config
3
+ from hexastack_otel.infra.middleware import TracingMiddleware
4
+
5
+ __all__ = [
6
+ "HexastackOtelConfig",
7
+ "OtelBootstrapper",
8
+ "register_otel_config",
9
+ "TracingMiddleware",
10
+ ]
@@ -0,0 +1,96 @@
1
+ from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SpanExporter
2
+
3
+ from hexastack_core.infra.bootstrap import BootstrapContext
4
+ from hexastack_core.infra.registries.config import ConfigRegistry
5
+ from hexastack_core.ports.bootstrap import BootstrapperPort
6
+ from hexastack_otel.adapters.tracing.in_memory import InMemoryTracingAdapter
7
+ from hexastack_otel.adapters.tracing.otel import OtelTracingAdapter
8
+ from hexastack_otel.infra.config import HexastackOtelConfig, register_otel_config
9
+ from hexastack_otel.infra.middleware import TracingMiddleware
10
+ from hexastack_otel.ports.tracing import TracingPort
11
+
12
+
13
+ class OtelBootstrapper(BootstrapperPort):
14
+ """Bootstrapper configuring OpenTelemetry tracing, exporters, and CQRS telemetry middleware.
15
+
16
+ Notes/Architectural Intent:
17
+ Executes at priority order=12 (after logging at 10, before auth at 16).
18
+ Registers TracingPort and TracingMiddleware into the rodi Container.
19
+ """
20
+
21
+ order: int = 12
22
+ name: str = "otel"
23
+
24
+ def configure(self, context: BootstrapContext) -> None:
25
+ """Assemble TracingPort and TracingMiddleware into the DI container in Phase 2.
26
+
27
+ Args:
28
+ context: The active BootstrapContext containing DI Container.
29
+ """
30
+ di = context.container
31
+
32
+ # 1. Read OTel Configuration
33
+ if HexastackOtelConfig in di:
34
+ cfg = di.resolve(HexastackOtelConfig)
35
+ else:
36
+ cfg = context.get_config("otel", HexastackOtelConfig)
37
+
38
+ # 2. Instantiate TracingPort Adapter
39
+ tracer: TracingPort
40
+ if cfg.exporter == "memory":
41
+ tracer = InMemoryTracingAdapter()
42
+ elif cfg.exporter == "console":
43
+ tracer = OtelTracingAdapter(
44
+ service_name=cfg.service_name,
45
+ exporter=ConsoleSpanExporter(),
46
+ )
47
+ elif cfg.exporter == "otlp_grpc":
48
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
49
+ OTLPSpanExporter as GrpcOTLPExporter,
50
+ )
51
+
52
+ grpc_exporter: SpanExporter = GrpcOTLPExporter(
53
+ endpoint=cfg.endpoint, insecure=True
54
+ )
55
+ tracer = OtelTracingAdapter(
56
+ service_name=cfg.service_name,
57
+ exporter=grpc_exporter,
58
+ )
59
+ elif cfg.exporter == "otlp_http":
60
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
61
+ OTLPSpanExporter as HttpOTLPExporter,
62
+ )
63
+
64
+ http_exporter: SpanExporter = HttpOTLPExporter(
65
+ endpoint=f"{cfg.endpoint}/v1/traces"
66
+ )
67
+ tracer = OtelTracingAdapter(
68
+ service_name=cfg.service_name,
69
+ exporter=http_exporter,
70
+ )
71
+ else:
72
+ tracer = InMemoryTracingAdapter()
73
+
74
+ # 3. Register TracingPort in DI Container
75
+ di.add_instance(tracer, declared_class=TracingPort)
76
+
77
+ # 4. Instantiate & Register TracingMiddleware
78
+ middleware = TracingMiddleware(tracer=tracer, enabled=cfg.enabled)
79
+ di.add_instance(middleware, declared_class=TracingMiddleware)
80
+
81
+ # 5. Store in context properties
82
+ context.properties["tracing_port"] = tracer
83
+ context.properties["otel_config"] = cfg
84
+
85
+ def register_config(self, registry: ConfigRegistry) -> None:
86
+ """Register the OpenTelemetry configuration section in Phase 1.
87
+
88
+ Args:
89
+ registry: The active ConfigRegistry instance.
90
+ """
91
+ register_otel_config(registry)
92
+
93
+
94
+ __all__ = [
95
+ "OtelBootstrapper",
96
+ ]
@@ -0,0 +1,52 @@
1
+ from typing import Literal
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ from hexastack_core.infra.decorators import config_section
6
+ from hexastack_core.infra.registries.config import ConfigRegistry
7
+
8
+
9
+ @config_section("otel")
10
+ class HexastackOtelConfig(BaseModel):
11
+ """Pydantic configuration model for OpenTelemetry tracing subsystem.
12
+
13
+ Notes/Architectural Intent:
14
+ Configured via `[hexastack.otel]` in pyproject.toml or hexastack.toml.
15
+ Supports in-memory, console, and gRPC/HTTP OTLP exporters.
16
+ """
17
+
18
+ service_name: str = Field(
19
+ default="hexastack-app",
20
+ description="Logical service name identifier attached to all telemetry spans.",
21
+ )
22
+ endpoint: str = Field(
23
+ default="http://localhost:4317",
24
+ description="OTLP collector endpoint URL (e.g. 'http://localhost:4317' or 'http://tempo:4317').",
25
+ )
26
+ exporter: Literal["memory", "console", "otlp_grpc", "otlp_http"] = Field(
27
+ default="memory",
28
+ description="Target span exporter backend ('memory', 'console', 'otlp_grpc', 'otlp_http').",
29
+ )
30
+ sample_rate: float = Field(
31
+ default=1.0,
32
+ description="Sampling probability ratio between 0.0 and 1.0.",
33
+ )
34
+ enabled: bool = Field(
35
+ default=True,
36
+ description="Master switch to activate or bypass tracing middleware.",
37
+ )
38
+
39
+
40
+ __all__ = [
41
+ "HexastackOtelConfig",
42
+ "register_otel_config",
43
+ ]
44
+
45
+
46
+ def register_otel_config(registry: ConfigRegistry) -> None:
47
+ """Register the OpenTelemetry config section schema in ConfigRegistry.
48
+
49
+ Args:
50
+ registry: Target ConfigRegistry instance.
51
+ """
52
+ registry.register_config_section("otel", HexastackOtelConfig)
@@ -0,0 +1,112 @@
1
+ import inspect
2
+ from collections.abc import Callable
3
+ from typing import Any, cast
4
+
5
+ from hexastack_core.domain import Command, Generic, Query
6
+ from hexastack_core.domain.feature_flags import EvaluationContext
7
+ from hexastack_core.ports.feature_flags import FeatureFlagPort
8
+ from hexastack_core.utils.context import get_correlation_id, get_user_context
9
+ from hexastack_otel.ports.tracing import TracingPort
10
+
11
+
12
+ class TracingMiddleware:
13
+ """CQRS middleware wrapping Command and Query execution in OpenTelemetry spans.
14
+
15
+ Notes/Architectural Intent:
16
+ Automatically creates distributed telemetry spans with correlation ID,
17
+ message type, tenant ID, and user ID attributes, recording execution
18
+ failures without altering domain handler logic.
19
+ Respects dynamic feature flag evaluation via FeatureFlagPort.
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ tracer: TracingPort,
25
+ enabled: bool = True,
26
+ flags: FeatureFlagPort | None = None,
27
+ ) -> None:
28
+ """Initialize tracing middleware with a TracingPort implementation.
29
+
30
+ Args:
31
+ tracer: TracingPort instance (Otel or InMemory).
32
+ enabled: Whether span creation is active.
33
+ flags: Optional FeatureFlagPort to dynamically evaluate tracing activation.
34
+ """
35
+ self._tracer = tracer
36
+ self._enabled = enabled
37
+ self._flags = flags
38
+
39
+ def __call__[G: Generic, R](
40
+ self,
41
+ instance: G,
42
+ next_call: Callable[[G], R],
43
+ ) -> R:
44
+ """Execute handler within a scoped telemetry span.
45
+
46
+ Args:
47
+ instance: Dispatched message instance.
48
+ next_call: Downstream handler or next middleware in chain.
49
+
50
+ Returns:
51
+ The handler result of type R.
52
+ """
53
+ if not self._enabled:
54
+ return next_call(instance)
55
+
56
+ if self._flags is not None:
57
+ eval_ctx = EvaluationContext.from_current_context()
58
+ if not self._flags.is_enabled(
59
+ "features.otel.tracing", default=True, context=eval_ctx
60
+ ):
61
+ return next_call(instance)
62
+
63
+ span_name = f"cqrs.{instance.__class__.__name__}"
64
+ attrs = self._build_attributes(instance)
65
+
66
+ with self._tracer.trace_scope(span_name, attributes=attrs) as span:
67
+ result = next_call(instance)
68
+
69
+ if inspect.isawaitable(result):
70
+
71
+ async def _async_wrap() -> Any:
72
+ try:
73
+ return await result
74
+ except BaseException as exc:
75
+ span.record_exception(exc)
76
+ raise
77
+
78
+ return cast("R", _async_wrap())
79
+
80
+ return result
81
+
82
+ def _build_attributes(self, instance: Generic) -> dict[str, Any]:
83
+ """Construct standard telemetry attributes from message context."""
84
+ msg_name = instance.__class__.__name__
85
+ msg_type = (
86
+ "command"
87
+ if isinstance(instance, Command)
88
+ else ("query" if isinstance(instance, Query) else "event")
89
+ )
90
+
91
+ attributes: dict[str, Any] = {
92
+ "message.name": msg_name,
93
+ "message.type": msg_type,
94
+ }
95
+
96
+ cid = get_correlation_id()
97
+ if cid:
98
+ attributes["correlation.id"] = cid
99
+
100
+ user_ctx = get_user_context()
101
+ if user_ctx:
102
+ if user_ctx.tenant_id:
103
+ attributes["tenant.id"] = user_ctx.tenant_id
104
+ if user_ctx.user_id:
105
+ attributes["user.id"] = user_ctx.user_id
106
+
107
+ return attributes
108
+
109
+
110
+ __all__ = [
111
+ "TracingMiddleware",
112
+ ]
@@ -0,0 +1,11 @@
1
+ from hexastack_otel.ports.tracing import (
2
+ SpanContext,
3
+ SpanPort,
4
+ TracingPort,
5
+ )
6
+
7
+ __all__ = [
8
+ "SpanContext",
9
+ "SpanPort",
10
+ "TracingPort",
11
+ ]
@@ -0,0 +1,133 @@
1
+ from abc import ABC, abstractmethod
2
+ from collections.abc import Iterator
3
+ from contextlib import contextmanager
4
+ from typing import Any
5
+
6
+ from hexastack_otel.domain.context import SpanContext
7
+
8
+
9
+ class SpanPort(ABC):
10
+ """Abstract port representing an in-flight or completed telemetry span.
11
+
12
+ Notes/Architectural Intent:
13
+ Decouples span instrumentation from underlying tracing backends.
14
+ """
15
+
16
+ @abstractmethod
17
+ def end(self) -> None:
18
+ """Complete the span recording."""
19
+
20
+ @abstractmethod
21
+ def record_exception(self, exception: BaseException) -> None:
22
+ """Record an exception on the span as a telemetry error event.
23
+
24
+ Args:
25
+ exception: The caught exception instance.
26
+ """
27
+
28
+ @abstractmethod
29
+ def set_attribute(self, key: str, value: Any) -> None:
30
+ """Set a single key-value attribute on the span.
31
+
32
+ Args:
33
+ key: Attribute name string.
34
+ value: Attribute value (primitive or list).
35
+ """
36
+
37
+ @abstractmethod
38
+ def set_attributes(self, attributes: dict[str, Any]) -> None:
39
+ """Set multiple attributes on the span in a single call.
40
+
41
+ Args:
42
+ attributes: Dictionary of key-value attributes.
43
+ """
44
+
45
+ @abstractmethod
46
+ def set_status(self, status: str, description: str | None = None) -> None:
47
+ """Set the span status ('OK', 'ERROR', 'UNSET').
48
+
49
+ Args:
50
+ status: Status string identifier.
51
+ description: Optional diagnostic message.
52
+ """
53
+
54
+
55
+ class TracingPort(ABC):
56
+ """Primary abstract port contract for distributed tracing and context propagation.
57
+
58
+ Notes/Architectural Intent:
59
+ Wraps OpenTelemetry tracer primitives into a clean hexagonal port interface.
60
+ Allows switching between OTel SDK, console exporter, or mock in-memory
61
+ recorders seamlessly.
62
+ """
63
+
64
+ @abstractmethod
65
+ def extract_context(self, carrier: dict[str, str]) -> SpanContext | None:
66
+ """Extract a SpanContext from an inbound dictionary carrier.
67
+
68
+ Args:
69
+ carrier: Inbound dictionary containing traceparent headers.
70
+
71
+ Returns:
72
+ Parsed SpanContext instance or None if no valid headers exist.
73
+ """
74
+
75
+ @abstractmethod
76
+ def get_current_span(self) -> SpanPort | None:
77
+ """Retrieve the currently active span in the current context/thread.
78
+
79
+ Returns:
80
+ Active SpanPort instance or None if no span is active.
81
+ """
82
+
83
+ @abstractmethod
84
+ def inject_context(self, carrier: dict[str, str]) -> None:
85
+ """Inject current trace context into an outbound dictionary carrier (e.g. HTTP headers).
86
+
87
+ Args:
88
+ carrier: Mutable dictionary to receive traceparent headers.
89
+ """
90
+
91
+ @abstractmethod
92
+ def start_span(
93
+ self,
94
+ name: str,
95
+ *,
96
+ attributes: dict[str, Any] | None = None,
97
+ parent_context: SpanContext | None = None,
98
+ ) -> SpanPort:
99
+ """Start a new telemetry span.
100
+
101
+ Args:
102
+ name: Span operation name.
103
+ attributes: Optional key-value attributes to attach.
104
+ parent_context: Optional parent SpanContext for remote linking.
105
+
106
+ Returns:
107
+ Active SpanPort instance.
108
+ """
109
+
110
+ @abstractmethod
111
+ @contextmanager
112
+ def trace_scope(
113
+ self,
114
+ name: str,
115
+ *,
116
+ attributes: dict[str, Any] | None = None,
117
+ ) -> Iterator[SpanPort]:
118
+ """Context manager creating a scoped span that automatically ends on exit.
119
+
120
+ Args:
121
+ name: Span operation name.
122
+ attributes: Optional key-value attributes to attach.
123
+
124
+ Yields:
125
+ Active SpanPort instance within the context block.
126
+ """
127
+
128
+
129
+ __all__ = [
130
+ "SpanContext",
131
+ "SpanPort",
132
+ "TracingPort",
133
+ ]