contexta 0.1.1__py3-none-any.whl
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.
- contexta/__init__.py +6 -0
- contexta/__main__.py +7 -0
- contexta/adapters/__init__.py +17 -0
- contexta/adapters/dataframes/__init__.py +9 -0
- contexta/adapters/export/__init__.py +21 -0
- contexta/adapters/html/__init__.py +29 -0
- contexta/adapters/mlflow/__init__.py +12 -0
- contexta/adapters/mlflow/_sink.py +179 -0
- contexta/adapters/notebook/__init__.py +29 -0
- contexta/adapters/otel/__init__.py +12 -0
- contexta/adapters/otel/_sink.py +272 -0
- contexta/api/__init__.py +5 -0
- contexta/api/client.py +612 -0
- contexta/capture/__init__.py +32 -0
- contexta/capture/_service_utils.py +142 -0
- contexta/capture/artifacts.py +325 -0
- contexta/capture/dispatch.py +316 -0
- contexta/capture/events.py +104 -0
- contexta/capture/metrics.py +114 -0
- contexta/capture/models.py +389 -0
- contexta/capture/results.py +518 -0
- contexta/capture/sinks/__init__.py +20 -0
- contexta/capture/sinks/composite.py +114 -0
- contexta/capture/sinks/local.py +70 -0
- contexta/capture/sinks/memory.py +72 -0
- contexta/capture/sinks/protocol.py +212 -0
- contexta/capture/sinks/stdout.py +56 -0
- contexta/capture/traces.py +124 -0
- contexta/common/__init__.py +3 -0
- contexta/common/errors.py +172 -0
- contexta/common/io.py +132 -0
- contexta/common/results.py +296 -0
- contexta/common/time.py +49 -0
- contexta/config/__init__.py +89 -0
- contexta/config/bootstrap.py +63 -0
- contexta/config/env.py +239 -0
- contexta/config/loader.py +374 -0
- contexta/config/models.py +660 -0
- contexta/contract/__init__.py +149 -0
- contexta/contract/extensions.py +233 -0
- contexta/contract/models/__init__.py +86 -0
- contexta/contract/models/artifacts.py +268 -0
- contexta/contract/models/context.py +903 -0
- contexta/contract/models/lineage.py +210 -0
- contexta/contract/models/records.py +671 -0
- contexta/contract/refs.py +224 -0
- contexta/contract/registry.py +313 -0
- contexta/contract/serialization/__init__.py +57 -0
- contexta/contract/serialization/canonical.py +509 -0
- contexta/contract/validation/__init__.py +45 -0
- contexta/contract/validation/core.py +449 -0
- contexta/contract/validation/report.py +149 -0
- contexta/interpretation/__init__.py +178 -0
- contexta/interpretation/aggregation/__init__.py +14 -0
- contexta/interpretation/aggregation/models.py +85 -0
- contexta/interpretation/aggregation/service.py +208 -0
- contexta/interpretation/alert/__init__.py +12 -0
- contexta/interpretation/alert/models.py +52 -0
- contexta/interpretation/alert/service.py +162 -0
- contexta/interpretation/anomaly/__init__.py +12 -0
- contexta/interpretation/anomaly/models.py +37 -0
- contexta/interpretation/anomaly/service.py +266 -0
- contexta/interpretation/compare/__init__.py +33 -0
- contexta/interpretation/compare/models.py +176 -0
- contexta/interpretation/compare/service.py +555 -0
- contexta/interpretation/diagnostics/__init__.py +12 -0
- contexta/interpretation/diagnostics/models.py +46 -0
- contexta/interpretation/diagnostics/service.py +181 -0
- contexta/interpretation/lineage/__init__.py +12 -0
- contexta/interpretation/lineage/models.py +43 -0
- contexta/interpretation/lineage/service.py +194 -0
- contexta/interpretation/protocols.py +68 -0
- contexta/interpretation/provenance/__init__.py +12 -0
- contexta/interpretation/provenance/models.py +69 -0
- contexta/interpretation/provenance/service.py +211 -0
- contexta/interpretation/query/__init__.py +14 -0
- contexta/interpretation/query/filters.py +96 -0
- contexta/interpretation/query/models.py +53 -0
- contexta/interpretation/query/service.py +286 -0
- contexta/interpretation/reports/__init__.py +12 -0
- contexta/interpretation/reports/builder.py +303 -0
- contexta/interpretation/reports/models.py +106 -0
- contexta/interpretation/repositories/__init__.py +27 -0
- contexta/interpretation/repositories/composite.py +541 -0
- contexta/interpretation/trend/__init__.py +27 -0
- contexta/interpretation/trend/models.py +108 -0
- contexta/interpretation/trend/service.py +290 -0
- contexta/notebook/__init__.py +23 -0
- contexta/recovery/__init__.py +34 -0
- contexta/recovery/backup.py +151 -0
- contexta/recovery/models.py +256 -0
- contexta/recovery/replay.py +185 -0
- contexta/recovery/restore.py +140 -0
- contexta/runtime/__init__.py +5 -0
- contexta/runtime/scopes.py +560 -0
- contexta/runtime/session.py +1090 -0
- contexta/store/__init__.py +10 -0
- contexta/store/artifacts/__init__.py +89 -0
- contexta/store/artifacts/config.py +194 -0
- contexta/store/artifacts/export.py +84 -0
- contexta/store/artifacts/importing.py +212 -0
- contexta/store/artifacts/ingest.py +106 -0
- contexta/store/artifacts/models.py +411 -0
- contexta/store/artifacts/read.py +92 -0
- contexta/store/artifacts/repair.py +206 -0
- contexta/store/artifacts/retention.py +41 -0
- contexta/store/artifacts/verify.py +219 -0
- contexta/store/artifacts/write.py +661 -0
- contexta/store/metadata/__init__.py +56 -0
- contexta/store/metadata/adapters/__init__.py +6 -0
- contexta/store/metadata/adapters/frame.py +66 -0
- contexta/store/metadata/adapters/sql.py +40 -0
- contexta/store/metadata/config.py +139 -0
- contexta/store/metadata/integrity/__init__.py +15 -0
- contexta/store/metadata/integrity/repair.py +90 -0
- contexta/store/metadata/integrity/report.py +210 -0
- contexta/store/metadata/migrations/__init__.py +31 -0
- contexta/store/metadata/migrations/models.py +99 -0
- contexta/store/metadata/migrations/runner.py +334 -0
- contexta/store/metadata/repositories/__init__.py +23 -0
- contexta/store/metadata/repositories/_base.py +72 -0
- contexta/store/metadata/repositories/batches.py +79 -0
- contexta/store/metadata/repositories/deployments.py +80 -0
- contexta/store/metadata/repositories/environments.py +52 -0
- contexta/store/metadata/repositories/projects.py +40 -0
- contexta/store/metadata/repositories/provenance.py +58 -0
- contexta/store/metadata/repositories/relations.py +80 -0
- contexta/store/metadata/repositories/runs.py +56 -0
- contexta/store/metadata/repositories/samples.py +94 -0
- contexta/store/metadata/repositories/stages.py +55 -0
- contexta/store/metadata/snapshots.py +119 -0
- contexta/store/metadata/store.py +465 -0
- contexta/store/records/__init__.py +47 -0
- contexta/store/records/config.py +190 -0
- contexta/store/records/export.py +33 -0
- contexta/store/records/integrity.py +142 -0
- contexta/store/records/models.py +372 -0
- contexta/store/records/read.py +131 -0
- contexta/store/records/repair.py +171 -0
- contexta/store/records/replay.py +182 -0
- contexta/store/records/write.py +455 -0
- contexta/surfaces/__init__.py +8 -0
- contexta/surfaces/cli/__init__.py +5 -0
- contexta/surfaces/cli/main.py +846 -0
- contexta/surfaces/export/__init__.py +15 -0
- contexta/surfaces/export/csv.py +155 -0
- contexta/surfaces/html/__init__.py +21 -0
- contexta/surfaces/html/charts.py +67 -0
- contexta/surfaces/html/renderer.py +376 -0
- contexta/surfaces/html/templates.py +158 -0
- contexta/surfaces/http/__init__.py +21 -0
- contexta/surfaces/http/serializers.py +139 -0
- contexta/surfaces/http/server.py +476 -0
- contexta/surfaces/notebook/__init__.py +23 -0
- contexta/surfaces/notebook/rendering.py +377 -0
- contexta/surfaces/notebook/surface.py +81 -0
- contexta-0.1.1.dist-info/METADATA +234 -0
- contexta-0.1.1.dist-info/RECORD +161 -0
- contexta-0.1.1.dist-info/WHEEL +4 -0
- contexta-0.1.1.dist-info/entry_points.txt +2 -0
- contexta-0.1.1.dist-info/licenses/LICENSE +21 -0
contexta/__init__.py
ADDED
contexta/__main__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Internal optional adapter namespace for Contexta.
|
|
2
|
+
|
|
3
|
+
This package is the home for optional integrations that are not required for
|
|
4
|
+
the base local-first runtime path.
|
|
5
|
+
|
|
6
|
+
Built-in lightweight adapters (no external vendor dependencies):
|
|
7
|
+
- ``export`` — CSV export helpers (stdlib only)
|
|
8
|
+
- ``html`` — HTML rendering helpers (stdlib only)
|
|
9
|
+
- ``notebook`` — Notebook display surface (IPython optional, degrades cleanly)
|
|
10
|
+
- ``dataframes`` — pandas/polars metadata query adapters (optional deps)
|
|
11
|
+
|
|
12
|
+
Vendor-gated adapters (raise DependencyError when deps are absent):
|
|
13
|
+
- ``otel`` — OpenTelemetry bridge (requires opentelemetry-api extra)
|
|
14
|
+
- ``mlflow`` — MLflow bridge (requires mlflow extra)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
__all__ = ["dataframes", "export", "html", "mlflow", "notebook", "otel"]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Optional dataframe adapter namespace for Contexta.
|
|
2
|
+
|
|
3
|
+
The current dataframe hooks are backed by metadata inspection adapters and are
|
|
4
|
+
re-exported here so future adapter-owned code can converge on this namespace.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ...store.metadata.adapters.frame import FrameAdapterProtocol, PandasAdapter, PolarsAdapter
|
|
8
|
+
|
|
9
|
+
__all__ = ["FrameAdapterProtocol", "PandasAdapter", "PolarsAdapter"]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Built-in export adapter namespace for Contexta.
|
|
2
|
+
|
|
3
|
+
Provides lightweight CSV export helpers that require only stdlib (csv, io).
|
|
4
|
+
These are the canonical import home for export-oriented integrations.
|
|
5
|
+
|
|
6
|
+
from contexta.adapters.export import export_run_list_csv, export_trend_csv
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ...surfaces.export import (
|
|
10
|
+
export_anomaly_csv,
|
|
11
|
+
export_comparison_csv,
|
|
12
|
+
export_run_list_csv,
|
|
13
|
+
export_trend_csv,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"export_anomaly_csv",
|
|
18
|
+
"export_comparison_csv",
|
|
19
|
+
"export_run_list_csv",
|
|
20
|
+
"export_trend_csv",
|
|
21
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Built-in HTML adapter namespace for Contexta.
|
|
2
|
+
|
|
3
|
+
Re-exports the HTML delivery surface for embedding run data in web UIs,
|
|
4
|
+
static reports, or custom dashboards. Requires only stdlib (html, string).
|
|
5
|
+
|
|
6
|
+
from contexta.adapters.html import render_html_run_detail, render_html_trend
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ...surfaces.html import (
|
|
10
|
+
DashboardConfig,
|
|
11
|
+
render_html_comparison,
|
|
12
|
+
render_html_dashboard,
|
|
13
|
+
render_html_run_detail,
|
|
14
|
+
render_html_run_list,
|
|
15
|
+
render_html_trend,
|
|
16
|
+
render_line_chart,
|
|
17
|
+
render_status_bar,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"DashboardConfig",
|
|
22
|
+
"render_html_comparison",
|
|
23
|
+
"render_html_dashboard",
|
|
24
|
+
"render_html_run_detail",
|
|
25
|
+
"render_html_run_list",
|
|
26
|
+
"render_html_trend",
|
|
27
|
+
"render_line_chart",
|
|
28
|
+
"render_status_bar",
|
|
29
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Optional MLflow adapter namespace for Contexta.
|
|
2
|
+
|
|
3
|
+
MLflow bridge code should enter through this package and remain outside the
|
|
4
|
+
base runtime dependency path.
|
|
5
|
+
|
|
6
|
+
Raises ``DependencyError`` at construction time when ``mlflow``
|
|
7
|
+
is not installed. Install it with: pip install 'contexta[mlflow]'
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._sink import MLflowSink
|
|
11
|
+
|
|
12
|
+
__all__ = ["MLflowSink"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""MLflow bridge sink — exports Contexta capture payloads to MLflow Tracking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from ...capture.sinks.protocol import BaseSink, SinkCaptureReceipt
|
|
8
|
+
from ...capture.results import PayloadFamily
|
|
9
|
+
from ...common.errors import DependencyError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ...contract import (
|
|
13
|
+
DegradedRecord,
|
|
14
|
+
MetricRecord,
|
|
15
|
+
StructuredEventRecord,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_MLFLOW_TAG_MAX_LEN = 5000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_mlflow():
|
|
22
|
+
"""Lazy import of mlflow — raises DependencyError if absent."""
|
|
23
|
+
try:
|
|
24
|
+
import mlflow
|
|
25
|
+
return mlflow
|
|
26
|
+
except ImportError as exc:
|
|
27
|
+
raise DependencyError(
|
|
28
|
+
"mlflow is required for MLflowSink. "
|
|
29
|
+
"Install it with: pip install 'contexta[mlflow]'",
|
|
30
|
+
code="mlflow_not_ready",
|
|
31
|
+
cause=exc,
|
|
32
|
+
) from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class MLflowSink(BaseSink):
|
|
36
|
+
"""Export Contexta capture payloads to the MLflow Tracking API.
|
|
37
|
+
|
|
38
|
+
Implements the ``Sink`` protocol so it can be passed directly to
|
|
39
|
+
``Contexta(sinks=[MLflowSink(...)])``.
|
|
40
|
+
|
|
41
|
+
Raises ``DependencyError`` on construction when ``mlflow``
|
|
42
|
+
is not installed.
|
|
43
|
+
|
|
44
|
+
Thread safety: tag-write cache is not thread-safe.
|
|
45
|
+
Use one instance per thread or protect externally.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
run_id: str | None = None,
|
|
52
|
+
name: str = "mlflow",
|
|
53
|
+
) -> None:
|
|
54
|
+
super().__init__(
|
|
55
|
+
name=name,
|
|
56
|
+
supported_families=(PayloadFamily.RECORD,),
|
|
57
|
+
)
|
|
58
|
+
# Eager import check — fail loudly on construction, not on first capture.
|
|
59
|
+
_load_mlflow()
|
|
60
|
+
|
|
61
|
+
self._run_id = run_id
|
|
62
|
+
# Track which tags have already been written to avoid write amplification.
|
|
63
|
+
# Keyed by the full tag key string.
|
|
64
|
+
self._written_tags: set[str] = set()
|
|
65
|
+
# Track whether the global run_ref tag has been set.
|
|
66
|
+
self._run_ref_written: bool = False
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------
|
|
69
|
+
# Sink protocol
|
|
70
|
+
# ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def capture(self, *, family: PayloadFamily | str, payload: object) -> SinkCaptureReceipt:
|
|
73
|
+
from ...contract import (
|
|
74
|
+
DegradedRecord,
|
|
75
|
+
MetricRecord,
|
|
76
|
+
StructuredEventRecord,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if not self.supports(family):
|
|
80
|
+
return SinkCaptureReceipt.success(detail="family not handled by MLflowSink")
|
|
81
|
+
|
|
82
|
+
if isinstance(payload, MetricRecord):
|
|
83
|
+
self._export_metric(payload)
|
|
84
|
+
return SinkCaptureReceipt.success(
|
|
85
|
+
detail=f"logged metric {payload.payload.metric_key}",
|
|
86
|
+
metadata={"metric_key": payload.payload.metric_key},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if isinstance(payload, StructuredEventRecord):
|
|
90
|
+
self._export_event(payload)
|
|
91
|
+
return SinkCaptureReceipt.success(
|
|
92
|
+
detail=f"tagged event {payload.payload.event_key}",
|
|
93
|
+
metadata={"event_key": payload.payload.event_key},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
if isinstance(payload, DegradedRecord):
|
|
97
|
+
self._export_degraded(payload)
|
|
98
|
+
return SinkCaptureReceipt.success(
|
|
99
|
+
detail=f"tagged degraded {payload.payload.issue_key}",
|
|
100
|
+
metadata={"issue_key": payload.payload.issue_key},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return SinkCaptureReceipt.success(detail="unrecognised record type; skipped")
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Export helpers
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def _log_metric(self, key: str, value: float) -> None:
|
|
110
|
+
mlflow = _load_mlflow()
|
|
111
|
+
if self._run_id is not None:
|
|
112
|
+
mlflow.log_metric(key, value, run_id=self._run_id)
|
|
113
|
+
else:
|
|
114
|
+
mlflow.log_metric(key, value)
|
|
115
|
+
|
|
116
|
+
def _set_tag(self, key: str, value: str) -> None:
|
|
117
|
+
mlflow = _load_mlflow()
|
|
118
|
+
if self._run_id is not None:
|
|
119
|
+
mlflow.set_tag(key, value, run_id=self._run_id)
|
|
120
|
+
else:
|
|
121
|
+
mlflow.set_tag(key, value)
|
|
122
|
+
|
|
123
|
+
def _set_tag_once(self, key: str, value: str) -> None:
|
|
124
|
+
"""Write a tag only on the first call for this key."""
|
|
125
|
+
if key not in self._written_tags:
|
|
126
|
+
self._written_tags.add(key)
|
|
127
|
+
self._set_tag(key, value)
|
|
128
|
+
|
|
129
|
+
def _ensure_run_ref_tag(self, run_ref: Any) -> None:
|
|
130
|
+
if not self._run_ref_written:
|
|
131
|
+
self._run_ref_written = True
|
|
132
|
+
self._set_tag_once("contexta.run_ref", str(run_ref))
|
|
133
|
+
|
|
134
|
+
def _export_metric(self, record: "MetricRecord") -> None:
|
|
135
|
+
payload = record.payload
|
|
136
|
+
envelope = record.envelope
|
|
137
|
+
|
|
138
|
+
self._ensure_run_ref_tag(envelope.run_ref)
|
|
139
|
+
if envelope.stage_execution_ref is not None:
|
|
140
|
+
self._set_tag_once("contexta.stage_ref", str(envelope.stage_execution_ref))
|
|
141
|
+
|
|
142
|
+
# Write unit tag once per metric key (not per observation)
|
|
143
|
+
if payload.unit:
|
|
144
|
+
unit_tag = f"contexta.metric_unit.{payload.metric_key}"
|
|
145
|
+
self._set_tag_once(unit_tag, payload.unit)
|
|
146
|
+
|
|
147
|
+
# Write metric tags once per metric key
|
|
148
|
+
if payload.tags:
|
|
149
|
+
for k, v in payload.tags.items():
|
|
150
|
+
tag_key = f"contexta.tag.{k}"
|
|
151
|
+
self._set_tag_once(tag_key, str(v))
|
|
152
|
+
|
|
153
|
+
self._log_metric(payload.metric_key, float(payload.value))
|
|
154
|
+
|
|
155
|
+
def _export_event(self, record: "StructuredEventRecord") -> None:
|
|
156
|
+
payload = record.payload
|
|
157
|
+
envelope = record.envelope
|
|
158
|
+
|
|
159
|
+
self._ensure_run_ref_tag(envelope.run_ref)
|
|
160
|
+
|
|
161
|
+
message = str(payload.message)[:_MLFLOW_TAG_MAX_LEN]
|
|
162
|
+
self._set_tag(f"contexta.event.{payload.event_key}", message)
|
|
163
|
+
self._set_tag_once(
|
|
164
|
+
f"contexta.event_level.{payload.event_key}",
|
|
165
|
+
payload.level,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
def _export_degraded(self, record: "DegradedRecord") -> None:
|
|
169
|
+
payload = record.payload
|
|
170
|
+
envelope = record.envelope
|
|
171
|
+
|
|
172
|
+
self._set_tag(
|
|
173
|
+
f"contexta.degraded.{payload.issue_key}",
|
|
174
|
+
f"{payload.category}:{payload.severity}",
|
|
175
|
+
)
|
|
176
|
+
self._set_tag_once("contexta.degraded_run_ref", str(envelope.run_ref))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = ["MLflowSink"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Optional notebook adapter namespace for Contexta.
|
|
2
|
+
|
|
3
|
+
Re-exports the notebook delivery surface so that notebook-aware code can
|
|
4
|
+
import from this canonical adapter boundary rather than from internal surfaces.
|
|
5
|
+
|
|
6
|
+
from contexta.adapters.notebook import NotebookSurface, display_run_snapshot
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ...surfaces.notebook import (
|
|
10
|
+
NotebookFragment,
|
|
11
|
+
NotebookSurface,
|
|
12
|
+
display_metric_trend,
|
|
13
|
+
display_run_comparison,
|
|
14
|
+
display_run_snapshot,
|
|
15
|
+
render_html_fragment,
|
|
16
|
+
to_pandas,
|
|
17
|
+
to_polars,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"NotebookFragment",
|
|
22
|
+
"NotebookSurface",
|
|
23
|
+
"display_metric_trend",
|
|
24
|
+
"display_run_comparison",
|
|
25
|
+
"display_run_snapshot",
|
|
26
|
+
"render_html_fragment",
|
|
27
|
+
"to_pandas",
|
|
28
|
+
"to_polars",
|
|
29
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Optional OpenTelemetry adapter namespace for Contexta.
|
|
2
|
+
|
|
3
|
+
OTel integration code should enter through this package and remain outside the
|
|
4
|
+
base runtime dependency path.
|
|
5
|
+
|
|
6
|
+
Raises ``DependencyError`` at construction time when ``opentelemetry-api``
|
|
7
|
+
is not installed. Install it with: pip install 'contexta[otel]'
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from ._sink import OTelSink
|
|
11
|
+
|
|
12
|
+
__all__ = ["OTelSink"]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""OTel bridge sink — exports Contexta capture payloads to OpenTelemetry."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from ...capture.sinks.protocol import BaseSink, SinkCaptureReceipt
|
|
8
|
+
from ...capture.results import PayloadFamily
|
|
9
|
+
from ...common.errors import DependencyError
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from ...contract import (
|
|
13
|
+
DegradedRecord,
|
|
14
|
+
MetricRecord,
|
|
15
|
+
StructuredEventRecord,
|
|
16
|
+
TraceSpanRecord,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _load_otel_trace():
|
|
21
|
+
"""Lazy import of opentelemetry.trace — raises DependencyError if absent."""
|
|
22
|
+
try:
|
|
23
|
+
import opentelemetry.trace as otel_trace
|
|
24
|
+
return otel_trace
|
|
25
|
+
except ImportError as exc:
|
|
26
|
+
raise DependencyError(
|
|
27
|
+
"opentelemetry-api is required for OTelSink. "
|
|
28
|
+
"Install it with: pip install 'contexta[otel]'",
|
|
29
|
+
code="otel_api_not_ready",
|
|
30
|
+
cause=exc,
|
|
31
|
+
) from exc
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _load_otel_metrics():
|
|
35
|
+
"""Lazy import of opentelemetry.metrics — raises DependencyError if absent."""
|
|
36
|
+
try:
|
|
37
|
+
import opentelemetry.metrics as otel_metrics
|
|
38
|
+
return otel_metrics
|
|
39
|
+
except ImportError as exc:
|
|
40
|
+
raise DependencyError(
|
|
41
|
+
"opentelemetry-api is required for OTelSink. "
|
|
42
|
+
"Install it with: pip install 'contexta[otel]'",
|
|
43
|
+
code="otel_api_not_ready",
|
|
44
|
+
cause=exc,
|
|
45
|
+
) from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _map_span_kind(contexta_kind: str) -> Any:
|
|
49
|
+
otel_trace = _load_otel_trace()
|
|
50
|
+
mapping = {
|
|
51
|
+
"operation": otel_trace.SpanKind.INTERNAL,
|
|
52
|
+
"internal": otel_trace.SpanKind.INTERNAL,
|
|
53
|
+
"io": otel_trace.SpanKind.CLIENT,
|
|
54
|
+
"network": otel_trace.SpanKind.CLIENT,
|
|
55
|
+
"process": otel_trace.SpanKind.PRODUCER,
|
|
56
|
+
}
|
|
57
|
+
return mapping.get(contexta_kind, otel_trace.SpanKind.INTERNAL)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _map_status_code(contexta_status: str) -> Any:
|
|
61
|
+
otel_trace = _load_otel_trace()
|
|
62
|
+
if contexta_status == "ok":
|
|
63
|
+
return otel_trace.StatusCode.OK
|
|
64
|
+
return otel_trace.StatusCode.ERROR
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class OTelSink(BaseSink):
|
|
68
|
+
"""Export Contexta capture payloads to the OpenTelemetry API.
|
|
69
|
+
|
|
70
|
+
Implements the ``Sink`` protocol so it can be passed directly to
|
|
71
|
+
``Contexta(sinks=[OTelSink(...)])``.
|
|
72
|
+
|
|
73
|
+
Raises ``DependencyError`` on construction when ``opentelemetry-api``
|
|
74
|
+
is not installed.
|
|
75
|
+
|
|
76
|
+
Thread safety: metric instrument cache is not thread-safe.
|
|
77
|
+
Use one instance per thread or protect externally.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
service_name: str = "contexta",
|
|
84
|
+
tracer_provider: Any = None,
|
|
85
|
+
meter_provider: Any = None,
|
|
86
|
+
name: str = "otel",
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(
|
|
89
|
+
name=name,
|
|
90
|
+
supported_families=(PayloadFamily.RECORD,),
|
|
91
|
+
)
|
|
92
|
+
# Eager import check — fail loudly on construction, not on first capture.
|
|
93
|
+
_load_otel_trace()
|
|
94
|
+
_load_otel_metrics()
|
|
95
|
+
|
|
96
|
+
self._service_name = service_name
|
|
97
|
+
self._tracer_provider = tracer_provider
|
|
98
|
+
self._meter_provider = meter_provider
|
|
99
|
+
self._tracer: Any = None
|
|
100
|
+
self._meter: Any = None
|
|
101
|
+
# (metric_name, unit) → Histogram instrument
|
|
102
|
+
self._histograms: dict[tuple[str, str], Any] = {}
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Sink protocol
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def capture(self, *, family: PayloadFamily | str, payload: object) -> SinkCaptureReceipt:
|
|
109
|
+
from ...contract import (
|
|
110
|
+
DegradedRecord,
|
|
111
|
+
MetricRecord,
|
|
112
|
+
StructuredEventRecord,
|
|
113
|
+
TraceSpanRecord,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if not self.supports(family):
|
|
117
|
+
return SinkCaptureReceipt.success(detail="family not handled by OTelSink")
|
|
118
|
+
|
|
119
|
+
if isinstance(payload, TraceSpanRecord):
|
|
120
|
+
self._export_span(payload)
|
|
121
|
+
return SinkCaptureReceipt.success(
|
|
122
|
+
detail=f"exported span {payload.payload.span_name}",
|
|
123
|
+
metadata={"span_name": payload.payload.span_name},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if isinstance(payload, MetricRecord):
|
|
127
|
+
self._export_metric(payload)
|
|
128
|
+
return SinkCaptureReceipt.success(
|
|
129
|
+
detail=f"recorded metric {payload.payload.metric_key}",
|
|
130
|
+
metadata={"metric_key": payload.payload.metric_key},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if isinstance(payload, StructuredEventRecord):
|
|
134
|
+
self._export_event(payload)
|
|
135
|
+
return SinkCaptureReceipt.success(
|
|
136
|
+
detail=f"added event {payload.payload.event_key}",
|
|
137
|
+
metadata={"event_key": payload.payload.event_key},
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
if isinstance(payload, DegradedRecord):
|
|
141
|
+
self._export_degraded(payload)
|
|
142
|
+
return SinkCaptureReceipt.success(
|
|
143
|
+
detail=f"added degraded event {payload.payload.issue_key}",
|
|
144
|
+
metadata={"issue_key": payload.payload.issue_key},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return SinkCaptureReceipt.success(detail="unrecognised record type; skipped")
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------
|
|
150
|
+
# Lazy tracer / meter
|
|
151
|
+
# ------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _get_tracer(self) -> Any:
|
|
154
|
+
if self._tracer is None:
|
|
155
|
+
otel_trace = _load_otel_trace()
|
|
156
|
+
provider = self._tracer_provider or otel_trace.get_tracer_provider()
|
|
157
|
+
self._tracer = provider.get_tracer(self._service_name)
|
|
158
|
+
return self._tracer
|
|
159
|
+
|
|
160
|
+
def _get_meter(self) -> Any:
|
|
161
|
+
if self._meter is None:
|
|
162
|
+
otel_metrics = _load_otel_metrics()
|
|
163
|
+
provider = self._meter_provider or otel_metrics.get_meter_provider()
|
|
164
|
+
self._meter = provider.get_meter(self._service_name)
|
|
165
|
+
return self._meter
|
|
166
|
+
|
|
167
|
+
def _get_histogram(self, metric_name: str, unit: str) -> Any:
|
|
168
|
+
key = (metric_name, unit)
|
|
169
|
+
if key not in self._histograms:
|
|
170
|
+
self._histograms[key] = self._get_meter().create_histogram(
|
|
171
|
+
name=metric_name,
|
|
172
|
+
unit=unit,
|
|
173
|
+
description=f"Contexta metric: {metric_name}",
|
|
174
|
+
)
|
|
175
|
+
return self._histograms[key]
|
|
176
|
+
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
# Export helpers
|
|
179
|
+
# ------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def _export_span(self, record: "TraceSpanRecord") -> None:
|
|
182
|
+
otel_trace = _load_otel_trace()
|
|
183
|
+
payload = record.payload
|
|
184
|
+
envelope = record.envelope
|
|
185
|
+
|
|
186
|
+
attrs: dict[str, Any] = {
|
|
187
|
+
"contexta.run_ref": str(envelope.run_ref),
|
|
188
|
+
"contexta.record_ref": str(envelope.record_ref),
|
|
189
|
+
"contexta.span_kind": payload.span_kind,
|
|
190
|
+
"contexta.span_id": payload.span_id,
|
|
191
|
+
"contexta.trace_id": payload.trace_id,
|
|
192
|
+
}
|
|
193
|
+
if envelope.stage_execution_ref is not None:
|
|
194
|
+
attrs["contexta.stage_ref"] = str(envelope.stage_execution_ref)
|
|
195
|
+
if payload.parent_span_id is not None:
|
|
196
|
+
attrs["contexta.parent_span_id"] = payload.parent_span_id
|
|
197
|
+
if payload.attributes:
|
|
198
|
+
for k, v in payload.attributes.items():
|
|
199
|
+
attrs[f"contexta.span.{k}"] = str(v)
|
|
200
|
+
|
|
201
|
+
span_kind = _map_span_kind(payload.span_kind)
|
|
202
|
+
status_code = _map_status_code(payload.status)
|
|
203
|
+
|
|
204
|
+
span = self._get_tracer().start_span(
|
|
205
|
+
payload.span_name,
|
|
206
|
+
kind=span_kind,
|
|
207
|
+
attributes=attrs,
|
|
208
|
+
)
|
|
209
|
+
try:
|
|
210
|
+
span.set_status(status_code)
|
|
211
|
+
finally:
|
|
212
|
+
span.end()
|
|
213
|
+
|
|
214
|
+
def _export_metric(self, record: "MetricRecord") -> None:
|
|
215
|
+
payload = record.payload
|
|
216
|
+
envelope = record.envelope
|
|
217
|
+
|
|
218
|
+
metric_name = f"contexta.{payload.metric_key}"
|
|
219
|
+
unit = payload.unit or "1"
|
|
220
|
+
|
|
221
|
+
attrs: dict[str, Any] = {
|
|
222
|
+
"contexta.run_ref": str(envelope.run_ref),
|
|
223
|
+
"contexta.aggregation_scope": payload.aggregation_scope,
|
|
224
|
+
"contexta.value_type": payload.value_type,
|
|
225
|
+
}
|
|
226
|
+
if envelope.stage_execution_ref is not None:
|
|
227
|
+
attrs["contexta.stage_ref"] = str(envelope.stage_execution_ref)
|
|
228
|
+
if payload.tags:
|
|
229
|
+
for k, v in payload.tags.items():
|
|
230
|
+
attrs[f"contexta.tag.{k}"] = v
|
|
231
|
+
|
|
232
|
+
histogram = self._get_histogram(metric_name, unit)
|
|
233
|
+
histogram.record(float(payload.value), attributes=attrs)
|
|
234
|
+
|
|
235
|
+
def _export_event(self, record: "StructuredEventRecord") -> None:
|
|
236
|
+
otel_trace = _load_otel_trace()
|
|
237
|
+
payload = record.payload
|
|
238
|
+
envelope = record.envelope
|
|
239
|
+
|
|
240
|
+
attrs: dict[str, Any] = {
|
|
241
|
+
"contexta.run_ref": str(envelope.run_ref),
|
|
242
|
+
"contexta.event_level": payload.level,
|
|
243
|
+
"contexta.event_message": payload.message,
|
|
244
|
+
}
|
|
245
|
+
if envelope.stage_execution_ref is not None:
|
|
246
|
+
attrs["contexta.stage_ref"] = str(envelope.stage_execution_ref)
|
|
247
|
+
if payload.attributes:
|
|
248
|
+
for k, v in payload.attributes.items():
|
|
249
|
+
attrs[f"contexta.event.{k}"] = str(v)
|
|
250
|
+
|
|
251
|
+
current_span = otel_trace.get_current_span()
|
|
252
|
+
current_span.add_event(payload.event_key, attributes=attrs)
|
|
253
|
+
|
|
254
|
+
def _export_degraded(self, record: "DegradedRecord") -> None:
|
|
255
|
+
otel_trace = _load_otel_trace()
|
|
256
|
+
payload = record.payload
|
|
257
|
+
envelope = record.envelope
|
|
258
|
+
|
|
259
|
+
attrs: dict[str, Any] = {
|
|
260
|
+
"contexta.run_ref": str(envelope.run_ref),
|
|
261
|
+
"contexta.issue_key": payload.issue_key,
|
|
262
|
+
"contexta.degradation_category": payload.category,
|
|
263
|
+
"contexta.severity": payload.severity,
|
|
264
|
+
}
|
|
265
|
+
if envelope.stage_execution_ref is not None:
|
|
266
|
+
attrs["contexta.stage_ref"] = str(envelope.stage_execution_ref)
|
|
267
|
+
|
|
268
|
+
current_span = otel_trace.get_current_span()
|
|
269
|
+
current_span.add_event("contexta.degraded", attributes=attrs)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
__all__ = ["OTelSink"]
|