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.
Files changed (161) hide show
  1. contexta/__init__.py +6 -0
  2. contexta/__main__.py +7 -0
  3. contexta/adapters/__init__.py +17 -0
  4. contexta/adapters/dataframes/__init__.py +9 -0
  5. contexta/adapters/export/__init__.py +21 -0
  6. contexta/adapters/html/__init__.py +29 -0
  7. contexta/adapters/mlflow/__init__.py +12 -0
  8. contexta/adapters/mlflow/_sink.py +179 -0
  9. contexta/adapters/notebook/__init__.py +29 -0
  10. contexta/adapters/otel/__init__.py +12 -0
  11. contexta/adapters/otel/_sink.py +272 -0
  12. contexta/api/__init__.py +5 -0
  13. contexta/api/client.py +612 -0
  14. contexta/capture/__init__.py +32 -0
  15. contexta/capture/_service_utils.py +142 -0
  16. contexta/capture/artifacts.py +325 -0
  17. contexta/capture/dispatch.py +316 -0
  18. contexta/capture/events.py +104 -0
  19. contexta/capture/metrics.py +114 -0
  20. contexta/capture/models.py +389 -0
  21. contexta/capture/results.py +518 -0
  22. contexta/capture/sinks/__init__.py +20 -0
  23. contexta/capture/sinks/composite.py +114 -0
  24. contexta/capture/sinks/local.py +70 -0
  25. contexta/capture/sinks/memory.py +72 -0
  26. contexta/capture/sinks/protocol.py +212 -0
  27. contexta/capture/sinks/stdout.py +56 -0
  28. contexta/capture/traces.py +124 -0
  29. contexta/common/__init__.py +3 -0
  30. contexta/common/errors.py +172 -0
  31. contexta/common/io.py +132 -0
  32. contexta/common/results.py +296 -0
  33. contexta/common/time.py +49 -0
  34. contexta/config/__init__.py +89 -0
  35. contexta/config/bootstrap.py +63 -0
  36. contexta/config/env.py +239 -0
  37. contexta/config/loader.py +374 -0
  38. contexta/config/models.py +660 -0
  39. contexta/contract/__init__.py +149 -0
  40. contexta/contract/extensions.py +233 -0
  41. contexta/contract/models/__init__.py +86 -0
  42. contexta/contract/models/artifacts.py +268 -0
  43. contexta/contract/models/context.py +903 -0
  44. contexta/contract/models/lineage.py +210 -0
  45. contexta/contract/models/records.py +671 -0
  46. contexta/contract/refs.py +224 -0
  47. contexta/contract/registry.py +313 -0
  48. contexta/contract/serialization/__init__.py +57 -0
  49. contexta/contract/serialization/canonical.py +509 -0
  50. contexta/contract/validation/__init__.py +45 -0
  51. contexta/contract/validation/core.py +449 -0
  52. contexta/contract/validation/report.py +149 -0
  53. contexta/interpretation/__init__.py +178 -0
  54. contexta/interpretation/aggregation/__init__.py +14 -0
  55. contexta/interpretation/aggregation/models.py +85 -0
  56. contexta/interpretation/aggregation/service.py +208 -0
  57. contexta/interpretation/alert/__init__.py +12 -0
  58. contexta/interpretation/alert/models.py +52 -0
  59. contexta/interpretation/alert/service.py +162 -0
  60. contexta/interpretation/anomaly/__init__.py +12 -0
  61. contexta/interpretation/anomaly/models.py +37 -0
  62. contexta/interpretation/anomaly/service.py +266 -0
  63. contexta/interpretation/compare/__init__.py +33 -0
  64. contexta/interpretation/compare/models.py +176 -0
  65. contexta/interpretation/compare/service.py +555 -0
  66. contexta/interpretation/diagnostics/__init__.py +12 -0
  67. contexta/interpretation/diagnostics/models.py +46 -0
  68. contexta/interpretation/diagnostics/service.py +181 -0
  69. contexta/interpretation/lineage/__init__.py +12 -0
  70. contexta/interpretation/lineage/models.py +43 -0
  71. contexta/interpretation/lineage/service.py +194 -0
  72. contexta/interpretation/protocols.py +68 -0
  73. contexta/interpretation/provenance/__init__.py +12 -0
  74. contexta/interpretation/provenance/models.py +69 -0
  75. contexta/interpretation/provenance/service.py +211 -0
  76. contexta/interpretation/query/__init__.py +14 -0
  77. contexta/interpretation/query/filters.py +96 -0
  78. contexta/interpretation/query/models.py +53 -0
  79. contexta/interpretation/query/service.py +286 -0
  80. contexta/interpretation/reports/__init__.py +12 -0
  81. contexta/interpretation/reports/builder.py +303 -0
  82. contexta/interpretation/reports/models.py +106 -0
  83. contexta/interpretation/repositories/__init__.py +27 -0
  84. contexta/interpretation/repositories/composite.py +541 -0
  85. contexta/interpretation/trend/__init__.py +27 -0
  86. contexta/interpretation/trend/models.py +108 -0
  87. contexta/interpretation/trend/service.py +290 -0
  88. contexta/notebook/__init__.py +23 -0
  89. contexta/recovery/__init__.py +34 -0
  90. contexta/recovery/backup.py +151 -0
  91. contexta/recovery/models.py +256 -0
  92. contexta/recovery/replay.py +185 -0
  93. contexta/recovery/restore.py +140 -0
  94. contexta/runtime/__init__.py +5 -0
  95. contexta/runtime/scopes.py +560 -0
  96. contexta/runtime/session.py +1090 -0
  97. contexta/store/__init__.py +10 -0
  98. contexta/store/artifacts/__init__.py +89 -0
  99. contexta/store/artifacts/config.py +194 -0
  100. contexta/store/artifacts/export.py +84 -0
  101. contexta/store/artifacts/importing.py +212 -0
  102. contexta/store/artifacts/ingest.py +106 -0
  103. contexta/store/artifacts/models.py +411 -0
  104. contexta/store/artifacts/read.py +92 -0
  105. contexta/store/artifacts/repair.py +206 -0
  106. contexta/store/artifacts/retention.py +41 -0
  107. contexta/store/artifacts/verify.py +219 -0
  108. contexta/store/artifacts/write.py +661 -0
  109. contexta/store/metadata/__init__.py +56 -0
  110. contexta/store/metadata/adapters/__init__.py +6 -0
  111. contexta/store/metadata/adapters/frame.py +66 -0
  112. contexta/store/metadata/adapters/sql.py +40 -0
  113. contexta/store/metadata/config.py +139 -0
  114. contexta/store/metadata/integrity/__init__.py +15 -0
  115. contexta/store/metadata/integrity/repair.py +90 -0
  116. contexta/store/metadata/integrity/report.py +210 -0
  117. contexta/store/metadata/migrations/__init__.py +31 -0
  118. contexta/store/metadata/migrations/models.py +99 -0
  119. contexta/store/metadata/migrations/runner.py +334 -0
  120. contexta/store/metadata/repositories/__init__.py +23 -0
  121. contexta/store/metadata/repositories/_base.py +72 -0
  122. contexta/store/metadata/repositories/batches.py +79 -0
  123. contexta/store/metadata/repositories/deployments.py +80 -0
  124. contexta/store/metadata/repositories/environments.py +52 -0
  125. contexta/store/metadata/repositories/projects.py +40 -0
  126. contexta/store/metadata/repositories/provenance.py +58 -0
  127. contexta/store/metadata/repositories/relations.py +80 -0
  128. contexta/store/metadata/repositories/runs.py +56 -0
  129. contexta/store/metadata/repositories/samples.py +94 -0
  130. contexta/store/metadata/repositories/stages.py +55 -0
  131. contexta/store/metadata/snapshots.py +119 -0
  132. contexta/store/metadata/store.py +465 -0
  133. contexta/store/records/__init__.py +47 -0
  134. contexta/store/records/config.py +190 -0
  135. contexta/store/records/export.py +33 -0
  136. contexta/store/records/integrity.py +142 -0
  137. contexta/store/records/models.py +372 -0
  138. contexta/store/records/read.py +131 -0
  139. contexta/store/records/repair.py +171 -0
  140. contexta/store/records/replay.py +182 -0
  141. contexta/store/records/write.py +455 -0
  142. contexta/surfaces/__init__.py +8 -0
  143. contexta/surfaces/cli/__init__.py +5 -0
  144. contexta/surfaces/cli/main.py +846 -0
  145. contexta/surfaces/export/__init__.py +15 -0
  146. contexta/surfaces/export/csv.py +155 -0
  147. contexta/surfaces/html/__init__.py +21 -0
  148. contexta/surfaces/html/charts.py +67 -0
  149. contexta/surfaces/html/renderer.py +376 -0
  150. contexta/surfaces/html/templates.py +158 -0
  151. contexta/surfaces/http/__init__.py +21 -0
  152. contexta/surfaces/http/serializers.py +139 -0
  153. contexta/surfaces/http/server.py +476 -0
  154. contexta/surfaces/notebook/__init__.py +23 -0
  155. contexta/surfaces/notebook/rendering.py +377 -0
  156. contexta/surfaces/notebook/surface.py +81 -0
  157. contexta-0.1.1.dist-info/METADATA +234 -0
  158. contexta-0.1.1.dist-info/RECORD +161 -0
  159. contexta-0.1.1.dist-info/WHEEL +4 -0
  160. contexta-0.1.1.dist-info/entry_points.txt +2 -0
  161. contexta-0.1.1.dist-info/licenses/LICENSE +21 -0
contexta/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Public root package for Contexta."""
2
+
3
+ from .api import Contexta, __version__
4
+ from .common.errors import ContextaError
5
+
6
+ __all__ = ["Contexta", "ContextaError", "__version__"]
contexta/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Entry point for python -m contexta."""
2
+
3
+ import sys
4
+
5
+ from contexta.surfaces.cli.main import main
6
+
7
+ sys.exit(main())
@@ -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"]
@@ -0,0 +1,5 @@
1
+ """Internal facade assembly package for Contexta."""
2
+
3
+ from .client import Contexta, __version__
4
+
5
+ __all__ = ["Contexta", "__version__"]