aevum-otel 0.6.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.
- aevum_otel-0.6.0/.gitignore +52 -0
- aevum_otel-0.6.0/PKG-INFO +86 -0
- aevum_otel-0.6.0/README.md +56 -0
- aevum_otel-0.6.0/pyproject.toml +73 -0
- aevum_otel-0.6.0/src/aevum/otel/__init__.py +32 -0
- aevum_otel-0.6.0/src/aevum/otel/bridge.py +186 -0
- aevum_otel-0.6.0/tests/__init__.py +0 -0
- aevum_otel-0.6.0/tests/test_bridge.py +227 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.venv/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
|
|
9
|
+
# Build
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
site/
|
|
13
|
+
|
|
14
|
+
# Tools
|
|
15
|
+
.mypy_cache/
|
|
16
|
+
.ruff_cache/
|
|
17
|
+
.pytest_cache/
|
|
18
|
+
.hypothesis/
|
|
19
|
+
.cache/
|
|
20
|
+
|
|
21
|
+
# IDE
|
|
22
|
+
.vscode/
|
|
23
|
+
.idea/
|
|
24
|
+
*.swp
|
|
25
|
+
*.swo
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Verify scripts (run locally, never commit)
|
|
32
|
+
verify_*.py
|
|
33
|
+
scripts/verify_*.py
|
|
34
|
+
|
|
35
|
+
# Aevum development — never commit (Phase 0+)
|
|
36
|
+
aevum_principles.key
|
|
37
|
+
signed_principles_draft.yaml
|
|
38
|
+
tools/sign_principles.py
|
|
39
|
+
|
|
40
|
+
# Private keys — never commit
|
|
41
|
+
*.key
|
|
42
|
+
*.pem
|
|
43
|
+
|
|
44
|
+
# OpenSSF Scorecard output (Phase 0+)
|
|
45
|
+
results.sarif
|
|
46
|
+
verify_phase3.py
|
|
47
|
+
verify_phase7.py
|
|
48
|
+
verify_phase8.py
|
|
49
|
+
verify_phase*.py
|
|
50
|
+
|
|
51
|
+
# Maintenance generated files — local only, never commit
|
|
52
|
+
maintenance/generated/
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aevum-otel
|
|
3
|
+
Version: 0.6.0
|
|
4
|
+
Summary: Aevum — OpenTelemetry bridge complication. Routes sigchain events to OTel GenAI spans.
|
|
5
|
+
Project-URL: Homepage, https://aevum.build
|
|
6
|
+
Project-URL: Repository, https://github.com/aevum-labs/aevum
|
|
7
|
+
Project-URL: Issues, https://github.com/aevum-labs/aevum/issues
|
|
8
|
+
License-Expression: Apache-2.0
|
|
9
|
+
Keywords: aevum,audit,genai,observability,opentelemetry,otel
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: aevum-core>=0.6.0
|
|
18
|
+
Requires-Dist: opentelemetry-api>=1.27.0
|
|
19
|
+
Requires-Dist: opentelemetry-sdk>=1.27.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: mypy>=1.9; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
25
|
+
Provides-Extra: otlp
|
|
26
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.27.0; extra == 'otlp'
|
|
27
|
+
Provides-Extra: otlp-http
|
|
28
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0; extra == 'otlp-http'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# aevum-otel
|
|
32
|
+
|
|
33
|
+
OpenTelemetry bridge complication for Aevum. Routes sigchain events to OTel GenAI spans.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install aevum-otel
|
|
39
|
+
# With OTLP HTTP exporter:
|
|
40
|
+
pip install "aevum-otel[otlp-http]"
|
|
41
|
+
# With OTLP gRPC exporter:
|
|
42
|
+
pip install "aevum-otel[otlp]"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from aevum.core import Engine
|
|
49
|
+
from aevum.otel import AevumOTelBridge
|
|
50
|
+
|
|
51
|
+
bridge = AevumOTelBridge(service_name="my-service")
|
|
52
|
+
engine = Engine()
|
|
53
|
+
engine.install_complication(bridge, auto_approve=True)
|
|
54
|
+
|
|
55
|
+
# All engine calls (ingest, query, etc.) now emit OTel GenAI spans.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Privacy defaults
|
|
59
|
+
|
|
60
|
+
By default only `audit_id` is emitted as `gen_ai.content.reference`. No prompt or response content is included.
|
|
61
|
+
|
|
62
|
+
To opt in to richer attributes:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## GenAI semantic conventions
|
|
69
|
+
|
|
70
|
+
For the latest experimental GenAI semconv:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
See [OTel GenAI semconv documentation](https://opentelemetry.io/docs/specs/semconv/gen-ai/) for details.
|
|
77
|
+
|
|
78
|
+
## Tested exporters
|
|
79
|
+
|
|
80
|
+
- Console exporter (always available via `opentelemetry-sdk`)
|
|
81
|
+
- Grafana Tempo (document setup if environment permits — otherwise note as untested)
|
|
82
|
+
- Langfuse (document setup if environment permits — otherwise note as untested)
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
Apache-2.0
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# aevum-otel
|
|
2
|
+
|
|
3
|
+
OpenTelemetry bridge complication for Aevum. Routes sigchain events to OTel GenAI spans.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install aevum-otel
|
|
9
|
+
# With OTLP HTTP exporter:
|
|
10
|
+
pip install "aevum-otel[otlp-http]"
|
|
11
|
+
# With OTLP gRPC exporter:
|
|
12
|
+
pip install "aevum-otel[otlp]"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from aevum.core import Engine
|
|
19
|
+
from aevum.otel import AevumOTelBridge
|
|
20
|
+
|
|
21
|
+
bridge = AevumOTelBridge(service_name="my-service")
|
|
22
|
+
engine = Engine()
|
|
23
|
+
engine.install_complication(bridge, auto_approve=True)
|
|
24
|
+
|
|
25
|
+
# All engine calls (ingest, query, etc.) now emit OTel GenAI spans.
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Privacy defaults
|
|
29
|
+
|
|
30
|
+
By default only `audit_id` is emitted as `gen_ai.content.reference`. No prompt or response content is included.
|
|
31
|
+
|
|
32
|
+
To opt in to richer attributes:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## GenAI semantic conventions
|
|
39
|
+
|
|
40
|
+
For the latest experimental GenAI semconv:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
See [OTel GenAI semconv documentation](https://opentelemetry.io/docs/specs/semconv/gen-ai/) for details.
|
|
47
|
+
|
|
48
|
+
## Tested exporters
|
|
49
|
+
|
|
50
|
+
- Console exporter (always available via `opentelemetry-sdk`)
|
|
51
|
+
- Grafana Tempo (document setup if environment permits — otherwise note as untested)
|
|
52
|
+
- Langfuse (document setup if environment permits — otherwise note as untested)
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
Apache-2.0
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aevum-otel"
|
|
7
|
+
version = "0.6.0"
|
|
8
|
+
description = "Aevum — OpenTelemetry bridge complication. Routes sigchain events to OTel GenAI spans."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
keywords = ["aevum", "opentelemetry", "otel", "observability", "genai", "audit"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"aevum-core>=0.6.0",
|
|
23
|
+
"opentelemetry-sdk>=1.27.0",
|
|
24
|
+
"opentelemetry-api>=1.27.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
otlp = [
|
|
29
|
+
"opentelemetry-exporter-otlp-proto-grpc>=1.27.0",
|
|
30
|
+
]
|
|
31
|
+
otlp-http = [
|
|
32
|
+
"opentelemetry-exporter-otlp-proto-http>=1.27.0",
|
|
33
|
+
]
|
|
34
|
+
dev = [
|
|
35
|
+
"pytest>=8.0",
|
|
36
|
+
"pytest-asyncio>=0.23",
|
|
37
|
+
"mypy>=1.9",
|
|
38
|
+
"ruff>=0.4",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://aevum.build"
|
|
43
|
+
Repository = "https://github.com/aevum-labs/aevum"
|
|
44
|
+
Issues = "https://github.com/aevum-labs/aevum/issues"
|
|
45
|
+
|
|
46
|
+
[tool.hatch.build.targets.wheel]
|
|
47
|
+
packages = ["src/aevum"]
|
|
48
|
+
|
|
49
|
+
[tool.uv.sources]
|
|
50
|
+
aevum-core = { workspace = true }
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
mypy_path = "src"
|
|
54
|
+
strict = true
|
|
55
|
+
python_version = "3.11"
|
|
56
|
+
ignore_missing_imports = true
|
|
57
|
+
|
|
58
|
+
[tool.ruff]
|
|
59
|
+
line-length = 130
|
|
60
|
+
target-version = "py311"
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint]
|
|
63
|
+
select = ["E", "F", "UP", "B", "SIM", "I", "ANN"]
|
|
64
|
+
ignore = ["ANN401"]
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint.per-file-ignores]
|
|
67
|
+
"tests/**" = ["ANN"]
|
|
68
|
+
|
|
69
|
+
[tool.pytest.ini_options]
|
|
70
|
+
testpaths = ["tests"]
|
|
71
|
+
asyncio_mode = "auto"
|
|
72
|
+
addopts = "--tb=short"
|
|
73
|
+
pythonpath = ["src", "tests"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024-2026 Aevum Labs contributors
|
|
3
|
+
"""
|
|
4
|
+
aevum-otel — OpenTelemetry bridge complication for Aevum.
|
|
5
|
+
|
|
6
|
+
Routes sigchain events to OpenTelemetry GenAI spans, publishing to any
|
|
7
|
+
OTLP-compatible backend (Grafana Tempo, Langfuse, Jaeger, etc.).
|
|
8
|
+
|
|
9
|
+
Privacy defaults:
|
|
10
|
+
- Only audit_id is emitted (as gen_ai.content.reference).
|
|
11
|
+
- No prompt, response, or payload content is emitted by default.
|
|
12
|
+
- Set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true to opt in.
|
|
13
|
+
|
|
14
|
+
GenAI semantic conventions:
|
|
15
|
+
- Set OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental for latest.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
from aevum.core import Engine
|
|
19
|
+
from aevum.otel import AevumOTelBridge
|
|
20
|
+
|
|
21
|
+
bridge = AevumOTelBridge(service_name="my-service")
|
|
22
|
+
engine = Engine()
|
|
23
|
+
engine.install_complication(bridge, auto_approve=True)
|
|
24
|
+
|
|
25
|
+
# Events from engine.ingest(), engine.query(), etc. will now appear
|
|
26
|
+
# as OTel spans in your configured OTLP backend.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from aevum.otel.bridge import AevumOTelBridge
|
|
30
|
+
|
|
31
|
+
__version__ = "0.6.0"
|
|
32
|
+
__all__ = ["AevumOTelBridge"]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2024-2026 Aevum Labs contributors
|
|
3
|
+
"""
|
|
4
|
+
AevumOTelBridge — sigchain events → OTel GenAI spans.
|
|
5
|
+
|
|
6
|
+
Privacy model:
|
|
7
|
+
Default: emit only audit_id as gen_ai.content.reference.
|
|
8
|
+
Opt-in: set OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true
|
|
9
|
+
to also emit event_type and actor in span attributes.
|
|
10
|
+
|
|
11
|
+
The bridge registers itself as a ledger observer and emits one OTel span
|
|
12
|
+
per AuditEvent. Span duration is always 0 (events are instantaneous writes).
|
|
13
|
+
|
|
14
|
+
Complication manifest:
|
|
15
|
+
name: "aevum-otel-bridge"
|
|
16
|
+
version: "0.6.0"
|
|
17
|
+
capabilities: ["telemetry.otel"]
|
|
18
|
+
|
|
19
|
+
Install via:
|
|
20
|
+
engine.install_complication(bridge, auto_approve=True)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from aevum.core.audit.event import AuditEvent
|
|
31
|
+
|
|
32
|
+
_logger = logging.getLogger("aevum.otel")
|
|
33
|
+
|
|
34
|
+
_MANIFEST: dict[str, Any] = {
|
|
35
|
+
"name": "aevum-otel-bridge",
|
|
36
|
+
"version": "0.6.0",
|
|
37
|
+
"schema_version": "1.0",
|
|
38
|
+
"capabilities": ["telemetry.otel"],
|
|
39
|
+
"description": "Routes Aevum sigchain events to OTel GenAI spans.",
|
|
40
|
+
"author": "Aevum Labs",
|
|
41
|
+
"classification_max": 0,
|
|
42
|
+
"functions": ["ingest", "query", "review", "commit", "replay"],
|
|
43
|
+
"auth": {"public_key": None},
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# OTel GenAI semantic convention attribute names
|
|
47
|
+
_ATTR_AUDIT_ID = "gen_ai.content.reference"
|
|
48
|
+
_ATTR_EVENT_TYPE = "aevum.event_type"
|
|
49
|
+
_ATTR_ACTOR = "aevum.actor"
|
|
50
|
+
_ATTR_SEQUENCE = "aevum.sequence"
|
|
51
|
+
_ATTR_EPISODE_ID = "aevum.episode_id"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _capture_content_enabled() -> bool:
|
|
55
|
+
return (
|
|
56
|
+
os.environ.get(
|
|
57
|
+
"OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", ""
|
|
58
|
+
).lower()
|
|
59
|
+
in ("true", "1", "yes")
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class AevumOTelBridge:
|
|
64
|
+
"""
|
|
65
|
+
OpenTelemetry bridge complication.
|
|
66
|
+
|
|
67
|
+
Subscribes to ledger events and emits OTel GenAI spans to the configured
|
|
68
|
+
OTLP endpoint (or any registered TracerProvider).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
service_name: OTel service name (default: "aevum").
|
|
72
|
+
tracer_provider: Optional pre-configured TracerProvider. If None,
|
|
73
|
+
uses the global OTel TracerProvider.
|
|
74
|
+
endpoint: Optional OTLP endpoint URL. If set, registers an
|
|
75
|
+
OTLP HTTP exporter (requires aevum-otel[otlp-http]).
|
|
76
|
+
If None, uses the global provider (e.g. console exporter
|
|
77
|
+
configured by the host application).
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
name: str = "aevum-otel-bridge"
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
service_name: str = "aevum",
|
|
86
|
+
tracer_provider: Any | None = None,
|
|
87
|
+
endpoint: str | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
from opentelemetry import trace
|
|
90
|
+
|
|
91
|
+
self._service_name = service_name
|
|
92
|
+
|
|
93
|
+
if tracer_provider is not None:
|
|
94
|
+
self._tracer_provider = tracer_provider
|
|
95
|
+
elif endpoint is not None:
|
|
96
|
+
self._tracer_provider = self._build_otlp_provider(service_name, endpoint)
|
|
97
|
+
else:
|
|
98
|
+
self._tracer_provider = trace.get_tracer_provider()
|
|
99
|
+
|
|
100
|
+
self._tracer = self._tracer_provider.get_tracer(
|
|
101
|
+
"aevum.otel.bridge",
|
|
102
|
+
schema_url="https://opentelemetry.io/schemas/1.28.0",
|
|
103
|
+
)
|
|
104
|
+
self._latency_samples: list[float] = []
|
|
105
|
+
|
|
106
|
+
def _build_otlp_provider(self, service_name: str, endpoint: str) -> Any:
|
|
107
|
+
"""Build a TracerProvider with OTLP HTTP exporter."""
|
|
108
|
+
from opentelemetry.sdk.resources import Resource
|
|
109
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
110
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
114
|
+
OTLPSpanExporter,
|
|
115
|
+
)
|
|
116
|
+
except ImportError as exc:
|
|
117
|
+
raise ImportError(
|
|
118
|
+
"OTLP HTTP exporter requires: pip install 'aevum-otel[otlp-http]'"
|
|
119
|
+
) from exc
|
|
120
|
+
|
|
121
|
+
resource = Resource.create({"service.name": service_name})
|
|
122
|
+
provider = TracerProvider(resource=resource)
|
|
123
|
+
exporter = OTLPSpanExporter(endpoint=endpoint)
|
|
124
|
+
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
125
|
+
return provider
|
|
126
|
+
|
|
127
|
+
def manifest(self) -> dict[str, Any]:
|
|
128
|
+
return _MANIFEST
|
|
129
|
+
|
|
130
|
+
def set_event_observer(self, ledger: Any) -> None:
|
|
131
|
+
"""Called by Engine.install_complication() to hook into ledger events."""
|
|
132
|
+
if hasattr(ledger, "add_observer"):
|
|
133
|
+
ledger.add_observer(self)
|
|
134
|
+
else:
|
|
135
|
+
_logger.warning(
|
|
136
|
+
"Ledger %r does not support add_observer — "
|
|
137
|
+
"AevumOTelBridge will not emit spans",
|
|
138
|
+
ledger,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def on_event(self, event: AuditEvent) -> None:
|
|
142
|
+
"""
|
|
143
|
+
Called for each AuditEvent appended to the ledger.
|
|
144
|
+
Emits one OTel span per event.
|
|
145
|
+
"""
|
|
146
|
+
import time # noqa: PLC0415
|
|
147
|
+
|
|
148
|
+
from opentelemetry.trace import SpanKind, StatusCode
|
|
149
|
+
|
|
150
|
+
t0 = time.monotonic()
|
|
151
|
+
capture = _capture_content_enabled()
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
with self._tracer.start_as_current_span(
|
|
155
|
+
f"aevum.{event.event_type}",
|
|
156
|
+
kind=SpanKind.INTERNAL,
|
|
157
|
+
) as span:
|
|
158
|
+
# Always-safe: audit reference only
|
|
159
|
+
span.set_attribute(_ATTR_AUDIT_ID, event.audit_id())
|
|
160
|
+
span.set_attribute(_ATTR_SEQUENCE, event.sequence)
|
|
161
|
+
|
|
162
|
+
if event.episode_id:
|
|
163
|
+
span.set_attribute(_ATTR_EPISODE_ID, event.episode_id)
|
|
164
|
+
|
|
165
|
+
if capture:
|
|
166
|
+
# Opt-in: richer attributes
|
|
167
|
+
span.set_attribute(_ATTR_EVENT_TYPE, event.event_type)
|
|
168
|
+
span.set_attribute(_ATTR_ACTOR, event.actor)
|
|
169
|
+
|
|
170
|
+
span.set_status(StatusCode.OK)
|
|
171
|
+
except Exception as exc: # noqa: BLE001
|
|
172
|
+
_logger.error("OTel span emission failed (suppressed): %s", exc)
|
|
173
|
+
finally:
|
|
174
|
+
elapsed_ms = (time.monotonic() - t0) * 1000
|
|
175
|
+
self._latency_samples.append(elapsed_ms)
|
|
176
|
+
|
|
177
|
+
def latency_p99_ms(self) -> float | None:
|
|
178
|
+
"""Return the p99 latency in ms over all observed events, or None if no data."""
|
|
179
|
+
if not self._latency_samples:
|
|
180
|
+
return None
|
|
181
|
+
sorted_samples = sorted(self._latency_samples)
|
|
182
|
+
idx = int(len(sorted_samples) * 0.99)
|
|
183
|
+
return sorted_samples[min(idx, len(sorted_samples) - 1)]
|
|
184
|
+
|
|
185
|
+
def reset_latency_samples(self) -> None:
|
|
186
|
+
self._latency_samples.clear()
|
|
File without changes
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Tests for AevumOTelBridge complication."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from unittest.mock import MagicMock
|
|
7
|
+
|
|
8
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
9
|
+
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
|
10
|
+
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
11
|
+
|
|
12
|
+
from aevum.otel import AevumOTelBridge
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _make_bridge() -> tuple[AevumOTelBridge, InMemorySpanExporter]:
|
|
16
|
+
"""Create a bridge wired to an in-memory span exporter for testing."""
|
|
17
|
+
exporter = InMemorySpanExporter()
|
|
18
|
+
provider = TracerProvider()
|
|
19
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
20
|
+
bridge = AevumOTelBridge(service_name="test-service", tracer_provider=provider)
|
|
21
|
+
return bridge, exporter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_mock_event(
|
|
25
|
+
event_type: str = "ingest.accepted",
|
|
26
|
+
actor: str = "test-agent",
|
|
27
|
+
sequence: int = 1,
|
|
28
|
+
episode_id: str | None = None,
|
|
29
|
+
) -> MagicMock:
|
|
30
|
+
event = MagicMock()
|
|
31
|
+
event.audit_id.return_value = "urn:aevum:audit:test-123"
|
|
32
|
+
event.event_type = event_type
|
|
33
|
+
event.actor = actor
|
|
34
|
+
event.sequence = sequence
|
|
35
|
+
event.episode_id = episode_id
|
|
36
|
+
return event
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── Manifest ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
class TestManifest:
|
|
42
|
+
def test_manifest_name(self):
|
|
43
|
+
bridge = AevumOTelBridge()
|
|
44
|
+
assert bridge.manifest()["name"] == "aevum-otel-bridge"
|
|
45
|
+
|
|
46
|
+
def test_manifest_capabilities(self):
|
|
47
|
+
bridge = AevumOTelBridge()
|
|
48
|
+
assert "telemetry.otel" in bridge.manifest()["capabilities"]
|
|
49
|
+
|
|
50
|
+
def test_name_attribute(self):
|
|
51
|
+
bridge = AevumOTelBridge()
|
|
52
|
+
assert bridge.name == "aevum-otel-bridge"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ── Span emission ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
class TestSpanEmission:
|
|
58
|
+
def test_emits_span_per_event(self):
|
|
59
|
+
bridge, exporter = _make_bridge()
|
|
60
|
+
bridge.on_event(_make_mock_event())
|
|
61
|
+
spans = exporter.get_finished_spans()
|
|
62
|
+
assert len(spans) == 1
|
|
63
|
+
|
|
64
|
+
def test_span_name_includes_event_type(self):
|
|
65
|
+
bridge, exporter = _make_bridge()
|
|
66
|
+
bridge.on_event(_make_mock_event(event_type="session.start"))
|
|
67
|
+
spans = exporter.get_finished_spans()
|
|
68
|
+
assert spans[0].name == "aevum.session.start"
|
|
69
|
+
|
|
70
|
+
def test_span_has_audit_id_attribute(self):
|
|
71
|
+
bridge, exporter = _make_bridge()
|
|
72
|
+
bridge.on_event(_make_mock_event())
|
|
73
|
+
spans = exporter.get_finished_spans()
|
|
74
|
+
attrs = dict(spans[0].attributes or {})
|
|
75
|
+
assert attrs.get("gen_ai.content.reference") == "urn:aevum:audit:test-123"
|
|
76
|
+
|
|
77
|
+
def test_span_has_sequence_attribute(self):
|
|
78
|
+
bridge, exporter = _make_bridge()
|
|
79
|
+
bridge.on_event(_make_mock_event(sequence=42))
|
|
80
|
+
spans = exporter.get_finished_spans()
|
|
81
|
+
attrs = dict(spans[0].attributes or {})
|
|
82
|
+
assert attrs.get("aevum.sequence") == 42
|
|
83
|
+
|
|
84
|
+
def test_no_content_in_default_mode(self, monkeypatch):
|
|
85
|
+
monkeypatch.delenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", raising=False)
|
|
86
|
+
bridge, exporter = _make_bridge()
|
|
87
|
+
bridge.on_event(_make_mock_event(actor="secret-agent"))
|
|
88
|
+
spans = exporter.get_finished_spans()
|
|
89
|
+
attrs = dict(spans[0].attributes or {})
|
|
90
|
+
# actor should NOT be emitted in default mode
|
|
91
|
+
assert "aevum.actor" not in attrs
|
|
92
|
+
assert "aevum.event_type" not in attrs
|
|
93
|
+
|
|
94
|
+
def test_content_emitted_when_opted_in(self, monkeypatch):
|
|
95
|
+
monkeypatch.setenv("OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "true")
|
|
96
|
+
bridge, exporter = _make_bridge()
|
|
97
|
+
bridge.on_event(_make_mock_event(event_type="ingest.accepted", actor="my-agent"))
|
|
98
|
+
spans = exporter.get_finished_spans()
|
|
99
|
+
attrs = dict(spans[0].attributes or {})
|
|
100
|
+
assert attrs.get("aevum.actor") == "my-agent"
|
|
101
|
+
assert attrs.get("aevum.event_type") == "ingest.accepted"
|
|
102
|
+
|
|
103
|
+
def test_episode_id_emitted_when_present(self):
|
|
104
|
+
bridge, exporter = _make_bridge()
|
|
105
|
+
bridge.on_event(_make_mock_event(episode_id="ep-001"))
|
|
106
|
+
spans = exporter.get_finished_spans()
|
|
107
|
+
attrs = dict(spans[0].attributes or {})
|
|
108
|
+
assert attrs.get("aevum.episode_id") == "ep-001"
|
|
109
|
+
|
|
110
|
+
def test_episode_id_absent_when_none(self):
|
|
111
|
+
bridge, exporter = _make_bridge()
|
|
112
|
+
bridge.on_event(_make_mock_event(episode_id=None))
|
|
113
|
+
spans = exporter.get_finished_spans()
|
|
114
|
+
attrs = dict(spans[0].attributes or {})
|
|
115
|
+
assert "aevum.episode_id" not in attrs
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ── Observer registration ─────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
class TestObserverRegistration:
|
|
121
|
+
def test_set_event_observer_registers_with_ledger(self):
|
|
122
|
+
bridge, _ = _make_bridge()
|
|
123
|
+
mock_ledger = MagicMock()
|
|
124
|
+
bridge.set_event_observer(mock_ledger)
|
|
125
|
+
mock_ledger.add_observer.assert_called_once_with(bridge)
|
|
126
|
+
|
|
127
|
+
def test_set_event_observer_warns_when_no_add_observer(self, caplog):
|
|
128
|
+
import logging
|
|
129
|
+
bridge, _ = _make_bridge()
|
|
130
|
+
ledger_without_observer = object() # no add_observer method
|
|
131
|
+
with caplog.at_level(logging.WARNING, logger="aevum.otel"):
|
|
132
|
+
bridge.set_event_observer(ledger_without_observer)
|
|
133
|
+
assert any("add_observer" in r.message for r in caplog.records)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ── Error resilience ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
class TestErrorResilience:
|
|
139
|
+
def test_on_event_does_not_raise_on_bad_event(self):
|
|
140
|
+
bridge, _ = _make_bridge()
|
|
141
|
+
bad_event = MagicMock()
|
|
142
|
+
bad_event.audit_id.side_effect = RuntimeError("broken")
|
|
143
|
+
# Should not raise
|
|
144
|
+
bridge.on_event(bad_event)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── Latency ───────────────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
class TestLatency:
|
|
150
|
+
def test_latency_p99_none_when_no_events(self):
|
|
151
|
+
bridge, _ = _make_bridge()
|
|
152
|
+
assert bridge.latency_p99_ms() is None
|
|
153
|
+
|
|
154
|
+
def test_latency_p99_populated_after_events(self):
|
|
155
|
+
bridge, _ = _make_bridge()
|
|
156
|
+
for _ in range(100):
|
|
157
|
+
bridge.on_event(_make_mock_event())
|
|
158
|
+
p99 = bridge.latency_p99_ms()
|
|
159
|
+
assert p99 is not None
|
|
160
|
+
assert p99 >= 0
|
|
161
|
+
|
|
162
|
+
def test_latency_reset(self):
|
|
163
|
+
bridge, _ = _make_bridge()
|
|
164
|
+
bridge.on_event(_make_mock_event())
|
|
165
|
+
bridge.reset_latency_samples()
|
|
166
|
+
assert bridge.latency_p99_ms() is None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ── Engine integration ────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
class TestEngineIntegration:
|
|
172
|
+
def test_bridge_receives_events_from_engine(self, monkeypatch):
|
|
173
|
+
"""Install bridge in Engine, verify spans are emitted."""
|
|
174
|
+
monkeypatch.setenv("AEVUM_DEV", "1")
|
|
175
|
+
from aevum.core.engine import Engine
|
|
176
|
+
|
|
177
|
+
exporter = InMemorySpanExporter()
|
|
178
|
+
provider = TracerProvider()
|
|
179
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
180
|
+
bridge = AevumOTelBridge(service_name="eng-test", tracer_provider=provider)
|
|
181
|
+
|
|
182
|
+
engine = Engine()
|
|
183
|
+
engine.install_complication(bridge, auto_approve=True)
|
|
184
|
+
|
|
185
|
+
engine.ingest(
|
|
186
|
+
data={"note": "otel test"},
|
|
187
|
+
provenance={"source_id": "t", "chain_of_custody": ["t"], "classification": 0},
|
|
188
|
+
purpose="test-otel",
|
|
189
|
+
subject_id="u1",
|
|
190
|
+
actor="a1",
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
spans = exporter.get_finished_spans()
|
|
194
|
+
span_names = [s.name for s in spans]
|
|
195
|
+
# ingest.accepted span should be present
|
|
196
|
+
assert any("ingest" in n for n in span_names), f"No ingest span in: {span_names}"
|
|
197
|
+
|
|
198
|
+
def test_bridge_p99_latency_under_2ms(self, monkeypatch):
|
|
199
|
+
"""
|
|
200
|
+
B-14: OTel latency overhead p99 must be < 2ms.
|
|
201
|
+
Uses in-memory span exporter (zero network overhead) to measure
|
|
202
|
+
pure bridge overhead.
|
|
203
|
+
"""
|
|
204
|
+
monkeypatch.setenv("AEVUM_DEV", "1")
|
|
205
|
+
from aevum.core.engine import Engine
|
|
206
|
+
|
|
207
|
+
exporter = InMemorySpanExporter()
|
|
208
|
+
provider = TracerProvider()
|
|
209
|
+
provider.add_span_processor(SimpleSpanProcessor(exporter))
|
|
210
|
+
bridge = AevumOTelBridge(service_name="bench", tracer_provider=provider)
|
|
211
|
+
|
|
212
|
+
engine = Engine()
|
|
213
|
+
engine.install_complication(bridge, auto_approve=True)
|
|
214
|
+
bridge.reset_latency_samples()
|
|
215
|
+
|
|
216
|
+
for i in range(200):
|
|
217
|
+
engine.ingest(
|
|
218
|
+
data={"n": i},
|
|
219
|
+
provenance={"source_id": "t", "chain_of_custody": ["t"], "classification": 0},
|
|
220
|
+
purpose="bench",
|
|
221
|
+
subject_id=f"u{i}",
|
|
222
|
+
actor="a1",
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
p99 = bridge.latency_p99_ms()
|
|
226
|
+
assert p99 is not None
|
|
227
|
+
assert p99 < 2.0, f"p99 latency {p99:.3f}ms exceeds 2ms threshold"
|