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.
- hexastack_otel-0.0.0/PKG-INFO +89 -0
- hexastack_otel-0.0.0/README.md +72 -0
- hexastack_otel-0.0.0/pyproject.toml +68 -0
- hexastack_otel-0.0.0/pyproject.toml.orig +65 -0
- hexastack_otel-0.0.0/src/hexastack_otel/__init__.py +8 -0
- hexastack_otel-0.0.0/src/hexastack_otel/adapters/__init__.py +13 -0
- hexastack_otel-0.0.0/src/hexastack_otel/adapters/tracing/__init__.py +15 -0
- hexastack_otel-0.0.0/src/hexastack_otel/adapters/tracing/in_memory.py +155 -0
- hexastack_otel-0.0.0/src/hexastack_otel/adapters/tracing/otel.py +138 -0
- hexastack_otel-0.0.0/src/hexastack_otel/domain/__init__.py +11 -0
- hexastack_otel-0.0.0/src/hexastack_otel/domain/context.py +21 -0
- hexastack_otel-0.0.0/src/hexastack_otel/domain/exceptions.py +20 -0
- hexastack_otel-0.0.0/src/hexastack_otel/infra/__init__.py +10 -0
- hexastack_otel-0.0.0/src/hexastack_otel/infra/bootstrap.py +96 -0
- hexastack_otel-0.0.0/src/hexastack_otel/infra/config.py +52 -0
- hexastack_otel-0.0.0/src/hexastack_otel/infra/middleware.py +112 -0
- hexastack_otel-0.0.0/src/hexastack_otel/ports/__init__.py +11 -0
- hexastack_otel-0.0.0/src/hexastack_otel/ports/tracing.py +133 -0
|
@@ -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,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,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,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
|
+
]
|