firecube 0.1.4__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 (197) hide show
  1. firecube/__init__.py +23 -0
  2. firecube/__main__.py +20 -0
  3. firecube/_version.py +46 -0
  4. firecube/cli/_audience.py +135 -0
  5. firecube/cli/_command_schemas.py +112 -0
  6. firecube/cli/_ctx.py +85 -0
  7. firecube/cli/_errors.py +115 -0
  8. firecube/cli/_formatter.py +195 -0
  9. firecube/cli/_product.py +47 -0
  10. firecube/cli/_rename_hints.py +123 -0
  11. firecube/cli/_shared_options.py +247 -0
  12. firecube/cli/_slot_env.py +102 -0
  13. firecube/cli/_slot_planning.py +95 -0
  14. firecube/cli/_typed_options.py +181 -0
  15. firecube/cli/_uri_policy.py +110 -0
  16. firecube/cli/advise.py +321 -0
  17. firecube/cli/archive.py +870 -0
  18. firecube/cli/catalog.py +196 -0
  19. firecube/cli/chunks/__init__.py +74 -0
  20. firecube/cli/chunks/_claims.py +229 -0
  21. firecube/cli/chunks/_common.py +84 -0
  22. firecube/cli/chunks/_delete.py +376 -0
  23. firecube/cli/chunks/_list.py +334 -0
  24. firecube/cli/chunks/_manager.py +161 -0
  25. firecube/cli/chunks/_runs.py +294 -0
  26. firecube/cli/chunks/_snapshots.py +165 -0
  27. firecube/cli/completion.py +73 -0
  28. firecube/cli/main.py +866 -0
  29. firecube/cli/parquet.py +289 -0
  30. firecube/cli/plugins/__init__.py +28 -0
  31. firecube/cli/plugins/commands.py +262 -0
  32. firecube/cli/plugins/introspect.py +304 -0
  33. firecube/cli/plugins/mgmt.py +198 -0
  34. firecube/cli/plugins/registry.py +132 -0
  35. firecube/cli/zarr.py +1043 -0
  36. firecube/core/__init__.py +14 -0
  37. firecube/core/api.py +88 -0
  38. firecube/core/cf/__init__.py +23 -0
  39. firecube/core/cf/check_ids.py +126 -0
  40. firecube/core/cf/report.py +57 -0
  41. firecube/core/cf/validator.py +313 -0
  42. firecube/core/config.py +307 -0
  43. firecube/core/controlplane/__init__.py +47 -0
  44. firecube/core/controlplane/_event_processor.py +136 -0
  45. firecube/core/controlplane/_helpers.py +114 -0
  46. firecube/core/controlplane/_projection.py +253 -0
  47. firecube/core/controlplane/_snapshot.py +215 -0
  48. firecube/core/controlplane/_wal_reader.py +402 -0
  49. firecube/core/controlplane/_wal_writer.py +589 -0
  50. firecube/core/controlplane/claims.py +294 -0
  51. firecube/core/controlplane/deletion.py +704 -0
  52. firecube/core/controlplane/events.py +219 -0
  53. firecube/core/controlplane/manager.py +990 -0
  54. firecube/core/controlplane/metrics.py +97 -0
  55. firecube/core/controlplane/repo.py +804 -0
  56. firecube/core/controlplane/repo_utils.py +73 -0
  57. firecube/core/controlplane/time_dim.py +111 -0
  58. firecube/core/controlplane/types.py +445 -0
  59. firecube/core/credentials.py +36 -0
  60. firecube/core/duckdb/__init__.py +27 -0
  61. firecube/core/duckdb/bridge.py +82 -0
  62. firecube/core/duckdb/io.py +30 -0
  63. firecube/core/errors.py +74 -0
  64. firecube/core/filesystem/__init__.py +60 -0
  65. firecube/core/filesystem/_obstore_compat.py +60 -0
  66. firecube/core/filesystem/fsspec_backend.py +308 -0
  67. firecube/core/filesystem/instrumentation.py +260 -0
  68. firecube/core/filesystem/obstore_backend.py +406 -0
  69. firecube/core/filesystem/ops.py +412 -0
  70. firecube/core/filesystem/protocol.py +130 -0
  71. firecube/core/filesystem/store_factory.py +149 -0
  72. firecube/core/formats/__init__.py +46 -0
  73. firecube/core/formats/_iso.py +84 -0
  74. firecube/core/formats/discovery.py +165 -0
  75. firecube/core/formats/hdf5.py +137 -0
  76. firecube/core/formats/netcdf.py +226 -0
  77. firecube/core/formats/zip.py +192 -0
  78. firecube/core/intake.py +530 -0
  79. firecube/core/observability/__init__.py +72 -0
  80. firecube/core/observability/_state.py +24 -0
  81. firecube/core/observability/logging.py +147 -0
  82. firecube/core/observability/metrics.py +230 -0
  83. firecube/core/observability/telemetry/__init__.py +87 -0
  84. firecube/core/observability/telemetry/pushgateway.py +339 -0
  85. firecube/core/observability/telemetry/sinks.py +155 -0
  86. firecube/core/observability/tracing.py +126 -0
  87. firecube/core/product/__init__.py +45 -0
  88. firecube/core/product/identity.py +76 -0
  89. firecube/core/product/resolver.py +121 -0
  90. firecube/core/product/target.py +120 -0
  91. firecube/core/runtime.py +187 -0
  92. firecube/core/slot_index.py +205 -0
  93. firecube/core/storage/__init__.py +45 -0
  94. firecube/core/storage/binding.py +39 -0
  95. firecube/core/storage/cache_key.py +40 -0
  96. firecube/core/storage/completion.py +245 -0
  97. firecube/core/storage/driver_config.py +125 -0
  98. firecube/core/storage/results.py +30 -0
  99. firecube/core/storage/session.py +431 -0
  100. firecube/core/storage/transfer.py +142 -0
  101. firecube/core/storage/uri.py +173 -0
  102. firecube/core/tensogram/__init__.py +37 -0
  103. firecube/core/tensogram/_compat.py +36 -0
  104. firecube/core/tensogram/controlplane_codec.py +230 -0
  105. firecube/core/tensogram/converter.py +390 -0
  106. firecube/core/tensogram/metadata.py +274 -0
  107. firecube/core/tensogram/restore.py +303 -0
  108. firecube/core/tensogram/schema.py +93 -0
  109. firecube/core/uris.py +182 -0
  110. firecube/core/workspaces.py +68 -0
  111. firecube/core/zarr/__init__.py +69 -0
  112. firecube/core/zarr/_reserved_attrs.py +47 -0
  113. firecube/core/zarr/_reserved_root_attrs.py +74 -0
  114. firecube/core/zarr/io.py +88 -0
  115. firecube/core/zarr/layers.py +184 -0
  116. firecube/core/zarr/multires.py +178 -0
  117. firecube/core/zarr/region_writer.py +782 -0
  118. firecube/core/zarr/scrub.py +200 -0
  119. firecube/core/zarr/state.py +258 -0
  120. firecube/core/zarr/time_decode.py +71 -0
  121. firecube/core/zarr/validation.py +622 -0
  122. firecube/ingestor/__init__.py +27 -0
  123. firecube/ingestor/api.py +215 -0
  124. firecube/ingestor/config/__init__.py +19 -0
  125. firecube/ingestor/config/coercion.py +157 -0
  126. firecube/ingestor/config/engine.py +188 -0
  127. firecube/ingestor/contracts/__init__.py +14 -0
  128. firecube/ingestor/contracts/interfaces.py +235 -0
  129. firecube/ingestor/devtools/_templates/__init__.py +14 -0
  130. firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
  131. firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
  132. firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
  133. firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
  134. firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
  135. firecube/ingestor/devtools/scaffolding.py +229 -0
  136. firecube/ingestor/errors.py +79 -0
  137. firecube/ingestor/extensions/_binning.py +152 -0
  138. firecube/ingestor/extensions/duck.py +153 -0
  139. firecube/ingestor/extensions/grid.py +287 -0
  140. firecube/ingestor/extensions/healpix.py +330 -0
  141. firecube/ingestor/registry/__init__.py +14 -0
  142. firecube/ingestor/registry/loader.py +144 -0
  143. firecube/ingestor/registry/metadata.py +97 -0
  144. firecube/ingestor/registry/version_compat.py +95 -0
  145. firecube/ingestor/runtime/__init__.py +14 -0
  146. firecube/ingestor/runtime/aggregation.py +112 -0
  147. firecube/ingestor/runtime/base.py +836 -0
  148. firecube/ingestor/runtime/base_hooks.py +129 -0
  149. firecube/ingestor/runtime/batching.py +161 -0
  150. firecube/ingestor/runtime/configure.py +195 -0
  151. firecube/ingestor/runtime/coverage.py +177 -0
  152. firecube/ingestor/runtime/engine.py +793 -0
  153. firecube/ingestor/runtime/parallel_evidence.py +75 -0
  154. firecube/ingestor/runtime/parallel_execution_state.py +28 -0
  155. firecube/ingestor/runtime/parallel_gate.py +164 -0
  156. firecube/ingestor/runtime/parallel_run_id.py +48 -0
  157. firecube/ingestor/runtime/preflight.py +40 -0
  158. firecube/ingestor/runtime/recording.py +425 -0
  159. firecube/ingestor/runtime/resume_guard.py +556 -0
  160. firecube/ingestor/runtime/resume_types.py +65 -0
  161. firecube/ingestor/runtime/telemetry.py +171 -0
  162. firecube/ingestor/runtime/tensogram/__init__.py +14 -0
  163. firecube/ingestor/runtime/tensogram/strategy.py +164 -0
  164. firecube/ingestor/runtime/workspace.py +220 -0
  165. firecube/ingestor/runtime/zarr/__init__.py +34 -0
  166. firecube/ingestor/runtime/zarr/append.py +466 -0
  167. firecube/ingestor/runtime/zarr/append_services.py +650 -0
  168. firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
  169. firecube/ingestor/runtime/zarr/contracts.py +58 -0
  170. firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
  171. firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
  172. firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
  173. firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
  174. firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
  175. firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
  176. firecube/ingestor/runtime/zarr/write.py +212 -0
  177. firecube/ingestor/runtime/zarr/write_context.py +139 -0
  178. firecube/ingestor/templates/__init__.py +19 -0
  179. firecube/ingestor/templates/config.py +118 -0
  180. firecube/ingestor/templates/direct_zarr.py +790 -0
  181. firecube/ingestor/templates/generic.py +462 -0
  182. firecube/ingestor/templates/generic_tensogram.py +170 -0
  183. firecube/ingestor/types/__init__.py +19 -0
  184. firecube/ingestor/types/config.py +89 -0
  185. firecube/ingestor/types/context.py +409 -0
  186. firecube/ingestor/types/manifest.py +53 -0
  187. firecube/ingestor/types/planned_range.py +152 -0
  188. firecube/ingestor/types/result_metrics.py +386 -0
  189. firecube/ingestor/utils/__init__.py +14 -0
  190. firecube/ingestor/utils/duckdb_utils.py +235 -0
  191. firecube/ingestor/validation.py +55 -0
  192. firecube-0.1.4.dist-info/METADATA +242 -0
  193. firecube-0.1.4.dist-info/RECORD +197 -0
  194. firecube-0.1.4.dist-info/WHEEL +4 -0
  195. firecube-0.1.4.dist-info/entry_points.txt +2 -0
  196. firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
  197. firecube-0.1.4.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,339 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import os
19
+ import re
20
+ import sys
21
+ from contextlib import contextmanager
22
+ from typing import Any, Literal
23
+
24
+ import psutil
25
+ from opentelemetry import trace
26
+ from prometheus_client import (
27
+ CollectorRegistry,
28
+ Counter,
29
+ Gauge,
30
+ delete_from_gateway,
31
+ push_to_gateway,
32
+ )
33
+
34
+ from firecube.core.config import load_config_file
35
+
36
+ log = logging.getLogger("firecube.core.observability.telemetry")
37
+
38
+ _LABEL_KEY_RE = re.compile(r"[^a-zA-Z0-9_]")
39
+ _METRIC_NAME_RE = re.compile(r"[^a-zA-Z0-9_]")
40
+
41
+
42
+ def sanitize_label_key(key: str) -> str:
43
+ """Normalize a label key to Prometheus-compatible format."""
44
+ safe = _LABEL_KEY_RE.sub("_", str(key).strip())
45
+ safe = safe.strip("_") or "label"
46
+ if not safe[0].isalpha() and safe[0] != "_":
47
+ safe = "_" + safe
48
+ return safe[:64]
49
+
50
+
51
+ def sanitize_label_value(value: Any) -> str:
52
+ """Normalize label values and cap potentially high-cardinality inputs."""
53
+ if value is None:
54
+ return ""
55
+ if isinstance(value, (int, float, bool)):
56
+ return str(value)
57
+ if isinstance(value, (list, tuple, set)):
58
+ items = list(value)
59
+ if len(items) > 5:
60
+ return ""
61
+ parts: list[str] = []
62
+ for item in items:
63
+ string_value = str(item)
64
+ if len(string_value) > 64:
65
+ return ""
66
+ parts.append(string_value)
67
+ return ",".join(parts)
68
+ if isinstance(value, dict):
69
+ return ""
70
+ return str(value)[:128]
71
+
72
+
73
+ def sanitize_metric_name(name: str) -> str:
74
+ """Normalize metric names and enforce the `firecube_` prefix."""
75
+ base = str(name).strip()
76
+ if not base:
77
+ base = "metric"
78
+ if not base.startswith("firecube_"):
79
+ base = "firecube_" + base
80
+ base = _METRIC_NAME_RE.sub("_", base)
81
+ if base[0].isdigit():
82
+ base = "firecube_" + base
83
+ return base
84
+
85
+
86
+ def normalize_name_for_kind(name: str, kind: Literal["gauge", "counter"]) -> str:
87
+ """Normalize metric suffixes according to metric kind."""
88
+ base = str(name).strip()
89
+ if kind == "counter" and not base.endswith("_total"):
90
+ base = base + "_total"
91
+ return base
92
+
93
+
94
+ def default_allowed_meta_keys() -> list[str]:
95
+ """Return default allowlist for telemetry metadata labels."""
96
+ return [
97
+ "group",
98
+ "forecast_horizon_hours",
99
+ "msg_region",
100
+ "satellite",
101
+ "status",
102
+ "error_type",
103
+ "resolution",
104
+ ]
105
+
106
+
107
+ def load_pushgateway_url() -> str | None:
108
+ """Resolve pushgateway URL from env vars or config file."""
109
+ env_url = os.getenv("FIRECUBE_PUSHGATEWAY_URL")
110
+ if env_url:
111
+ return str(env_url).strip() or None
112
+
113
+ cfg = load_config_file()
114
+ metrics_cfg = cfg.get("metrics", {}) if isinstance(cfg, dict) else {}
115
+ if isinstance(metrics_cfg, dict):
116
+ url = metrics_cfg.get("pushgateway_url")
117
+ if url:
118
+ return str(url).strip() or None
119
+ return None
120
+
121
+
122
+ def _iter_allowed_meta_keys(value: Any) -> list[str]:
123
+ """Normalize config or env label lists into a flat string list.
124
+
125
+ The sink stays generic by accepting either a TOML list or a comma-separated
126
+ string. That keeps new product labels opt-in through configuration rather
127
+ than requiring code changes in the telemetry sink.
128
+ """
129
+ if value is None:
130
+ return []
131
+ if isinstance(value, str):
132
+ return [item.strip() for item in value.split(",") if item.strip()]
133
+ if isinstance(value, (list, tuple, set)):
134
+ return [str(item).strip() for item in value if str(item).strip()]
135
+ return []
136
+
137
+
138
+ def load_allowed_meta_keys() -> list[str]:
139
+ """Load label allowlist from defaults plus config/env overrides."""
140
+ keys = set(default_allowed_meta_keys())
141
+
142
+ cfg = load_config_file()
143
+ metrics_cfg = cfg.get("metrics", {}) if isinstance(cfg, dict) else {}
144
+ if isinstance(metrics_cfg, dict):
145
+ for raw in _iter_allowed_meta_keys(metrics_cfg.get("label_allowlist")):
146
+ keys.add(raw)
147
+
148
+ for raw in _iter_allowed_meta_keys(os.getenv("FIRECUBE_METRICS_LABEL_ALLOWLIST", "")):
149
+ keys.add(raw)
150
+
151
+ return sorted({sanitize_label_key(key) for key in keys})
152
+
153
+
154
+ class PushGatewayIngestionTelemetry:
155
+ """Telemetry sink that buffers metrics and pushes once to Pushgateway."""
156
+
157
+ @property
158
+ def run_id(self) -> str:
159
+ return self._run_id
160
+
161
+ def __init__(
162
+ self,
163
+ *,
164
+ plugin: str,
165
+ product: str,
166
+ output_format: str | None = None,
167
+ write_mode: str | None = None,
168
+ run_id: str,
169
+ pushgateway_url: str,
170
+ base_meta: dict[str, Any] | None = None,
171
+ ):
172
+ self._plugin = str(plugin)
173
+ self._product = str(product)
174
+ self._output_format = str(output_format or "")
175
+ self._write_mode = str(write_mode or "")
176
+ self._run_id = str(run_id)
177
+ self._gateway = str(pushgateway_url).strip()
178
+ self._tracer = trace.get_tracer(f"firecube.ingestor.{self._plugin}")
179
+
180
+ self._instance = str(os.getenv("HOSTNAME") or os.getenv("COMPUTERNAME") or "host")
181
+ self._job = str(os.getenv("FIRECUBE_PUSHGATEWAY_JOB") or "firecube").strip() or "firecube"
182
+ self._delete_after_push = str(
183
+ os.getenv("FIRECUBE_PUSHGATEWAY_DELETE_AFTER_PUSH") or "false"
184
+ ).lower() in {"1", "true", "yes", "on"}
185
+
186
+ self._allowed_meta_keys = load_allowed_meta_keys()
187
+ include_run_id_label = str(
188
+ os.getenv("FIRECUBE_METRICS_INCLUDE_RUN_ID_LABEL") or "false"
189
+ ).lower() in {"1", "true", "yes", "on"}
190
+ self._include_run_id_label = bool(include_run_id_label)
191
+ base_labels = ["plugin", "product", "output_format", "write_mode"]
192
+ if self._include_run_id_label:
193
+ base_labels.append("run_id")
194
+ self._labelnames = [*base_labels, *self._allowed_meta_keys]
195
+ self._registry = CollectorRegistry()
196
+ self._gauges: dict[str, Gauge] = {}
197
+ self._counters: dict[str, Counter] = {}
198
+ self._base_meta = dict(base_meta or {})
199
+
200
+ raw_keys = str(os.getenv("FIRECUBE_PUSHGATEWAY_GROUPING_KEYS") or "instance,plugin,product")
201
+ grouping_keys = [sanitize_label_key(key) for key in raw_keys.split(",") if key.strip()]
202
+ grouping: dict[str, str] = {}
203
+ for key in grouping_keys:
204
+ if key == "plugin":
205
+ grouping[key] = sanitize_label_value(self._plugin)
206
+ elif key == "product":
207
+ grouping[key] = sanitize_label_value(self._product)
208
+ elif key == "run_id":
209
+ grouping[key] = sanitize_label_value(self._run_id)
210
+ elif key == "instance":
211
+ grouping[key] = sanitize_label_value(self._instance)
212
+ else:
213
+ env_key = f"FIRECUBE_PUSHGATEWAY_GROUP_{key.upper()}"
214
+ grouping[key] = sanitize_label_value(os.getenv(env_key))
215
+ self._grouping_key = {k: v for k, v in grouping.items() if isinstance(v, str) and v != ""}
216
+
217
+ def _labels(self, meta: dict[str, Any] | None) -> dict[str, str]:
218
+ merged = dict(self._base_meta)
219
+ if isinstance(meta, dict):
220
+ merged.update(meta)
221
+
222
+ labels: dict[str, str] = {
223
+ "plugin": self._plugin,
224
+ "product": self._product,
225
+ "output_format": self._output_format,
226
+ "write_mode": self._write_mode,
227
+ }
228
+ if self._include_run_id_label:
229
+ labels["run_id"] = self._run_id
230
+ for key in self._allowed_meta_keys:
231
+ labels[key] = sanitize_label_value(merged.get(key))
232
+ return labels
233
+
234
+ def emit(
235
+ self,
236
+ name: str,
237
+ value: float,
238
+ *,
239
+ kind: Literal["gauge", "counter"] = "gauge",
240
+ meta: dict[str, Any] | None = None,
241
+ ) -> None:
242
+ metric_name = sanitize_metric_name(normalize_name_for_kind(name, kind))
243
+ labels = self._labels(meta)
244
+
245
+ if kind == "counter":
246
+ counter = self._counters.get(metric_name)
247
+ if counter is None:
248
+ counter = Counter(
249
+ metric_name,
250
+ "Firecube emitted counter",
251
+ self._labelnames,
252
+ registry=self._registry,
253
+ )
254
+ self._counters[metric_name] = counter
255
+ try:
256
+ counter.labels(**labels).inc(float(value))
257
+ except Exception:
258
+ return None
259
+ return None
260
+
261
+ gauge = self._gauges.get(metric_name)
262
+ if gauge is None:
263
+ gauge = Gauge(
264
+ metric_name, "Firecube emitted gauge", self._labelnames, registry=self._registry
265
+ )
266
+ self._gauges[metric_name] = gauge
267
+ try:
268
+ gauge.labels(**labels).set(float(value))
269
+ except Exception:
270
+ return None
271
+ return None
272
+
273
+ def flush(self) -> None:
274
+ if not self._gauges and not self._counters:
275
+ return None
276
+ try:
277
+ push_to_gateway(
278
+ self._gateway,
279
+ job=self._job,
280
+ grouping_key=self._grouping_key or None,
281
+ registry=self._registry,
282
+ )
283
+ except Exception as exc:
284
+ log.warning(
285
+ "Failed to push metrics to Pushgateway",
286
+ extra={
287
+ "pushgateway_url": self._gateway,
288
+ "job": self._job,
289
+ "grouping_key": dict(self._grouping_key),
290
+ "error": str(exc),
291
+ },
292
+ )
293
+ return None
294
+ if not self._delete_after_push:
295
+ return None
296
+ try:
297
+ delete_from_gateway(
298
+ self._gateway,
299
+ job=self._job,
300
+ grouping_key=self._grouping_key or None,
301
+ )
302
+ except Exception as exc:
303
+ log.warning(
304
+ "Failed to delete metrics from Pushgateway after push",
305
+ extra={
306
+ "pushgateway_url": self._gateway,
307
+ "job": self._job,
308
+ "grouping_key": dict(self._grouping_key),
309
+ "error": str(exc),
310
+ },
311
+ )
312
+ return None
313
+
314
+ @contextmanager
315
+ def span(self, name: str, attributes: dict[str, Any] | None = None):
316
+ attrs = dict(attributes or {})
317
+ kfp_run_id = os.getenv("KFP_RUN_ID")
318
+ if kfp_run_id:
319
+ attrs["kfp_run_id"] = kfp_run_id
320
+
321
+ with self._tracer.start_as_current_span(str(name), attributes=attrs):
322
+ yield
323
+
324
+ def collect_memory_stats(self) -> None:
325
+ try:
326
+ try:
327
+ import resource
328
+
329
+ usage = resource.getrusage(resource.RUSAGE_SELF)
330
+ rss = usage.ru_maxrss
331
+ if sys.platform != "darwin":
332
+ rss *= 1024
333
+ self.emit("process_memory_peak_rss_bytes", float(rss), kind="gauge")
334
+ except ImportError:
335
+ process = psutil.Process()
336
+ mem_info = process.memory_info()
337
+ self.emit("process_memory_rss_bytes", float(mem_info.rss), kind="gauge")
338
+ except Exception:
339
+ pass
@@ -0,0 +1,155 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from contextlib import AbstractContextManager, contextmanager, nullcontext
19
+ from typing import Any, Literal, Protocol
20
+
21
+ from opentelemetry import trace
22
+
23
+
24
+ class IngestionTelemetry(Protocol):
25
+ """Backend-agnostic ingestion telemetry interface (metrics + tracing)."""
26
+
27
+ @property
28
+ def run_id(self) -> str: ...
29
+
30
+ def emit(
31
+ self,
32
+ name: str,
33
+ value: float,
34
+ *,
35
+ kind: Literal["gauge", "counter"] = "gauge",
36
+ meta: dict[str, Any] | None = None,
37
+ ) -> None: ...
38
+
39
+ def flush(self) -> None: ...
40
+
41
+ def span(
42
+ self, name: str, attributes: dict[str, Any] | None = None
43
+ ) -> AbstractContextManager[None]: ...
44
+
45
+ def collect_memory_stats(self) -> None: ...
46
+
47
+
48
+ class NoopIngestionTelemetry:
49
+ """No-op telemetry sink used when telemetry is not configured."""
50
+
51
+ def __init__(self, run_id: str = "unknown"):
52
+ self._run_id = str(run_id)
53
+
54
+ @property
55
+ def run_id(self) -> str:
56
+ return self._run_id
57
+
58
+ def emit(
59
+ self,
60
+ name: str,
61
+ value: float,
62
+ *,
63
+ kind: Literal["gauge", "counter"] = "gauge",
64
+ meta: dict[str, Any] | None = None,
65
+ ) -> None:
66
+ _ = (name, value, kind, meta)
67
+ return None
68
+
69
+ def flush(self) -> None:
70
+ return None
71
+
72
+ def span(self, name: str, attributes: dict[str, Any] | None = None):
73
+ _ = (name, attributes)
74
+ return nullcontext()
75
+
76
+ def collect_memory_stats(self) -> None:
77
+ return None
78
+
79
+
80
+ class TracingIngestionTelemetry:
81
+ """Tracing-only telemetry sink that keeps span context active."""
82
+
83
+ def __init__(self, *, plugin: str, run_id: str):
84
+ self._plugin = str(plugin)
85
+ self._run_id = str(run_id)
86
+ self._tracer = trace.get_tracer(f"firecube.ingestor.{self._plugin}")
87
+
88
+ @property
89
+ def run_id(self) -> str:
90
+ return self._run_id
91
+
92
+ def emit(
93
+ self,
94
+ name: str,
95
+ value: float,
96
+ *,
97
+ kind: Literal["gauge", "counter"] = "gauge",
98
+ meta: dict[str, Any] | None = None,
99
+ ) -> None:
100
+ _ = (name, value, kind, meta)
101
+ return None
102
+
103
+ def flush(self) -> None:
104
+ return None
105
+
106
+ @contextmanager
107
+ def span(self, name: str, attributes: dict[str, Any] | None = None):
108
+ attrs = dict(attributes or {})
109
+ kfp_run_id = os.getenv("KFP_RUN_ID")
110
+ if kfp_run_id:
111
+ attrs["kfp_run_id"] = kfp_run_id
112
+
113
+ with self._tracer.start_as_current_span(str(name), attributes=attrs):
114
+ yield
115
+
116
+ def collect_memory_stats(self) -> None:
117
+ return None
118
+
119
+
120
+ class CompositeIngestionTelemetry:
121
+ """Dispatches telemetry operations to multiple sinks."""
122
+
123
+ def __init__(self, sinks: list[IngestionTelemetry]):
124
+ self._sinks = sinks
125
+
126
+ @property
127
+ def run_id(self) -> str:
128
+ return self._sinks[0].run_id if self._sinks else "unknown"
129
+
130
+ def emit(
131
+ self,
132
+ name: str,
133
+ value: float,
134
+ *,
135
+ kind: Literal["gauge", "counter"] = "gauge",
136
+ meta: dict[str, Any] | None = None,
137
+ ) -> None:
138
+ for sink in self._sinks:
139
+ sink.emit(name, value, kind=kind, meta=meta)
140
+
141
+ def flush(self) -> None:
142
+ for sink in self._sinks:
143
+ sink.flush()
144
+
145
+ def collect_memory_stats(self) -> None:
146
+ for sink in self._sinks:
147
+ sink.collect_memory_stats()
148
+
149
+ @contextmanager
150
+ def span(self, name: str, attributes: dict[str, Any] | None = None):
151
+ if not self._sinks:
152
+ yield
153
+ return
154
+ with self._sinks[0].span(name, attributes):
155
+ yield
@@ -0,0 +1,126 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import logging
16
+ import os
17
+ from collections.abc import Iterator
18
+ from contextlib import contextmanager
19
+ from typing import Any
20
+
21
+ from opentelemetry import context as _otel_context
22
+ from opentelemetry import trace
23
+ from opentelemetry.context import Context
24
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
25
+ from opentelemetry.sdk.resources import Resource
26
+ from opentelemetry.sdk.trace import TracerProvider
27
+ from opentelemetry.sdk.trace.export import (
28
+ BatchSpanProcessor,
29
+ ConsoleSpanExporter,
30
+ SimpleSpanProcessor,
31
+ )
32
+
33
+ _TRACER_PROVIDER: TracerProvider | None = None
34
+
35
+
36
+ def configure_tracing(service_name: str = "firecube-service"):
37
+ """Configure OpenTelemetry tracing based on environment variables."""
38
+ global _TRACER_PROVIDER
39
+ if _TRACER_PROVIDER is not None:
40
+ return _TRACER_PROVIDER
41
+
42
+ debug = os.getenv("OTEL_DEBUG", "false").lower() in ("1", "true", "yes")
43
+ endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
44
+ headers = os.getenv("OTEL_EXPORTER_OTLP_HEADERS", "")
45
+ existing_provider = getattr(trace, "_TRACER_PROVIDER", None)
46
+
47
+ if isinstance(existing_provider, TracerProvider):
48
+ provider = existing_provider
49
+ else:
50
+ resource = Resource.create({"service.name": service_name})
51
+ provider = TracerProvider(resource=resource)
52
+ trace.set_tracer_provider(provider)
53
+
54
+ if endpoint:
55
+ exporter = OTLPSpanExporter(endpoint=endpoint, headers=headers) # type: ignore[arg-type]
56
+ provider.add_span_processor(BatchSpanProcessor(exporter))
57
+ logging.info("Tracing configured (OTLP endpoint=%s)", endpoint)
58
+ elif debug:
59
+ provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
60
+ logging.info("Tracing in DEBUG console mode (no OTLP endpoint)")
61
+ else:
62
+ logging.debug("Tracing disabled (no OTLP endpoint, debug off)")
63
+
64
+ _TRACER_PROVIDER = provider
65
+
66
+ return provider
67
+
68
+
69
+ def shutdown_tracing(timeout_millis: int = 5000) -> bool:
70
+ """Flush pending spans and shut down the active TracerProvider.
71
+
72
+ Returns True on clean shutdown, False if no provider is active or shutdown fails.
73
+ Uses half the given timeout for force_flush and the full timeout for provider shutdown.
74
+ """
75
+ global _TRACER_PROVIDER
76
+ provider = _TRACER_PROVIDER
77
+ if provider is None:
78
+ return False
79
+ try:
80
+ provider.force_flush(timeout_millis=timeout_millis // 2)
81
+ except Exception as exc:
82
+ logging.warning("TracerProvider force_flush failed: %s", exc)
83
+ try:
84
+ provider.shutdown()
85
+ _TRACER_PROVIDER = None
86
+ return True
87
+ except Exception as exc:
88
+ logging.warning("TracerProvider shutdown failed: %s", exc)
89
+ _TRACER_PROVIDER = None
90
+ return False
91
+
92
+
93
+ def span(name: str, attributes: dict[str, Any] | None = None):
94
+ """Open a named span as a context manager."""
95
+ tracer = trace.get_tracer("firecube")
96
+ return tracer.start_as_current_span(name, attributes=attributes)
97
+
98
+
99
+ def set_current_span_attribute(key: str, value: Any) -> None:
100
+ """Set an attribute on the currently active span. No-op if no active span."""
101
+ trace.get_current_span().set_attribute(key, value)
102
+
103
+
104
+ def capture_context() -> Context:
105
+ """Capture the current OTel context for cross-thread propagation."""
106
+ return _otel_context.get_current()
107
+
108
+
109
+ def attach_context(ctx: Context) -> object:
110
+ """Attach a captured context; returns a token for detach()."""
111
+ return _otel_context.attach(ctx)
112
+
113
+
114
+ def detach_context(token: object) -> None:
115
+ """Detach a previously attached context."""
116
+ _otel_context.detach(token) # type: ignore[arg-type]
117
+
118
+
119
+ @contextmanager
120
+ def propagated_context(ctx: Context) -> Iterator[None]:
121
+ """Context manager that attaches ctx on enter and detaches on exit."""
122
+ token = attach_context(ctx)
123
+ try:
124
+ yield
125
+ finally:
126
+ detach_context(token)
@@ -0,0 +1,45 @@
1
+ # Copyright 2025-2026 EUMETSAT
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Product identity, target resolution, and product URI helpers.
16
+
17
+ Public imports remain stable at ``firecube.core.product``.
18
+ """
19
+
20
+ from firecube.core.product.identity import (
21
+ VALID_FORMATS,
22
+ ProductIdentity,
23
+ ensure_product_uri,
24
+ )
25
+ from firecube.core.product.resolver import (
26
+ CompletionRoute,
27
+ ProductResolver,
28
+ WriteModePolicy,
29
+ resolve_dataset_target,
30
+ write_mode_policy,
31
+ )
32
+ from firecube.core.product.target import ProductTarget, ResolvedProduct
33
+
34
+ __all__ = [
35
+ "VALID_FORMATS",
36
+ "CompletionRoute",
37
+ "ProductIdentity",
38
+ "ProductResolver",
39
+ "ProductTarget",
40
+ "ResolvedProduct",
41
+ "WriteModePolicy",
42
+ "ensure_product_uri",
43
+ "resolve_dataset_target",
44
+ "write_mode_policy",
45
+ ]