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.
- firecube/__init__.py +23 -0
- firecube/__main__.py +20 -0
- firecube/_version.py +46 -0
- firecube/cli/_audience.py +135 -0
- firecube/cli/_command_schemas.py +112 -0
- firecube/cli/_ctx.py +85 -0
- firecube/cli/_errors.py +115 -0
- firecube/cli/_formatter.py +195 -0
- firecube/cli/_product.py +47 -0
- firecube/cli/_rename_hints.py +123 -0
- firecube/cli/_shared_options.py +247 -0
- firecube/cli/_slot_env.py +102 -0
- firecube/cli/_slot_planning.py +95 -0
- firecube/cli/_typed_options.py +181 -0
- firecube/cli/_uri_policy.py +110 -0
- firecube/cli/advise.py +321 -0
- firecube/cli/archive.py +870 -0
- firecube/cli/catalog.py +196 -0
- firecube/cli/chunks/__init__.py +74 -0
- firecube/cli/chunks/_claims.py +229 -0
- firecube/cli/chunks/_common.py +84 -0
- firecube/cli/chunks/_delete.py +376 -0
- firecube/cli/chunks/_list.py +334 -0
- firecube/cli/chunks/_manager.py +161 -0
- firecube/cli/chunks/_runs.py +294 -0
- firecube/cli/chunks/_snapshots.py +165 -0
- firecube/cli/completion.py +73 -0
- firecube/cli/main.py +866 -0
- firecube/cli/parquet.py +289 -0
- firecube/cli/plugins/__init__.py +28 -0
- firecube/cli/plugins/commands.py +262 -0
- firecube/cli/plugins/introspect.py +304 -0
- firecube/cli/plugins/mgmt.py +198 -0
- firecube/cli/plugins/registry.py +132 -0
- firecube/cli/zarr.py +1043 -0
- firecube/core/__init__.py +14 -0
- firecube/core/api.py +88 -0
- firecube/core/cf/__init__.py +23 -0
- firecube/core/cf/check_ids.py +126 -0
- firecube/core/cf/report.py +57 -0
- firecube/core/cf/validator.py +313 -0
- firecube/core/config.py +307 -0
- firecube/core/controlplane/__init__.py +47 -0
- firecube/core/controlplane/_event_processor.py +136 -0
- firecube/core/controlplane/_helpers.py +114 -0
- firecube/core/controlplane/_projection.py +253 -0
- firecube/core/controlplane/_snapshot.py +215 -0
- firecube/core/controlplane/_wal_reader.py +402 -0
- firecube/core/controlplane/_wal_writer.py +589 -0
- firecube/core/controlplane/claims.py +294 -0
- firecube/core/controlplane/deletion.py +704 -0
- firecube/core/controlplane/events.py +219 -0
- firecube/core/controlplane/manager.py +990 -0
- firecube/core/controlplane/metrics.py +97 -0
- firecube/core/controlplane/repo.py +804 -0
- firecube/core/controlplane/repo_utils.py +73 -0
- firecube/core/controlplane/time_dim.py +111 -0
- firecube/core/controlplane/types.py +445 -0
- firecube/core/credentials.py +36 -0
- firecube/core/duckdb/__init__.py +27 -0
- firecube/core/duckdb/bridge.py +82 -0
- firecube/core/duckdb/io.py +30 -0
- firecube/core/errors.py +74 -0
- firecube/core/filesystem/__init__.py +60 -0
- firecube/core/filesystem/_obstore_compat.py +60 -0
- firecube/core/filesystem/fsspec_backend.py +308 -0
- firecube/core/filesystem/instrumentation.py +260 -0
- firecube/core/filesystem/obstore_backend.py +406 -0
- firecube/core/filesystem/ops.py +412 -0
- firecube/core/filesystem/protocol.py +130 -0
- firecube/core/filesystem/store_factory.py +149 -0
- firecube/core/formats/__init__.py +46 -0
- firecube/core/formats/_iso.py +84 -0
- firecube/core/formats/discovery.py +165 -0
- firecube/core/formats/hdf5.py +137 -0
- firecube/core/formats/netcdf.py +226 -0
- firecube/core/formats/zip.py +192 -0
- firecube/core/intake.py +530 -0
- firecube/core/observability/__init__.py +72 -0
- firecube/core/observability/_state.py +24 -0
- firecube/core/observability/logging.py +147 -0
- firecube/core/observability/metrics.py +230 -0
- firecube/core/observability/telemetry/__init__.py +87 -0
- firecube/core/observability/telemetry/pushgateway.py +339 -0
- firecube/core/observability/telemetry/sinks.py +155 -0
- firecube/core/observability/tracing.py +126 -0
- firecube/core/product/__init__.py +45 -0
- firecube/core/product/identity.py +76 -0
- firecube/core/product/resolver.py +121 -0
- firecube/core/product/target.py +120 -0
- firecube/core/runtime.py +187 -0
- firecube/core/slot_index.py +205 -0
- firecube/core/storage/__init__.py +45 -0
- firecube/core/storage/binding.py +39 -0
- firecube/core/storage/cache_key.py +40 -0
- firecube/core/storage/completion.py +245 -0
- firecube/core/storage/driver_config.py +125 -0
- firecube/core/storage/results.py +30 -0
- firecube/core/storage/session.py +431 -0
- firecube/core/storage/transfer.py +142 -0
- firecube/core/storage/uri.py +173 -0
- firecube/core/tensogram/__init__.py +37 -0
- firecube/core/tensogram/_compat.py +36 -0
- firecube/core/tensogram/controlplane_codec.py +230 -0
- firecube/core/tensogram/converter.py +390 -0
- firecube/core/tensogram/metadata.py +274 -0
- firecube/core/tensogram/restore.py +303 -0
- firecube/core/tensogram/schema.py +93 -0
- firecube/core/uris.py +182 -0
- firecube/core/workspaces.py +68 -0
- firecube/core/zarr/__init__.py +69 -0
- firecube/core/zarr/_reserved_attrs.py +47 -0
- firecube/core/zarr/_reserved_root_attrs.py +74 -0
- firecube/core/zarr/io.py +88 -0
- firecube/core/zarr/layers.py +184 -0
- firecube/core/zarr/multires.py +178 -0
- firecube/core/zarr/region_writer.py +782 -0
- firecube/core/zarr/scrub.py +200 -0
- firecube/core/zarr/state.py +258 -0
- firecube/core/zarr/time_decode.py +71 -0
- firecube/core/zarr/validation.py +622 -0
- firecube/ingestor/__init__.py +27 -0
- firecube/ingestor/api.py +215 -0
- firecube/ingestor/config/__init__.py +19 -0
- firecube/ingestor/config/coercion.py +157 -0
- firecube/ingestor/config/engine.py +188 -0
- firecube/ingestor/contracts/__init__.py +14 -0
- firecube/ingestor/contracts/interfaces.py +235 -0
- firecube/ingestor/devtools/_templates/__init__.py +14 -0
- firecube/ingestor/devtools/_templates/ingestor_base.py.tpl +72 -0
- firecube/ingestor/devtools/_templates/ingestor_direct_zarr.py.tpl +66 -0
- firecube/ingestor/devtools/_templates/ingestor_parquet.py.tpl +59 -0
- firecube/ingestor/devtools/_templates/ingestor_zarr.py.tpl +55 -0
- firecube/ingestor/devtools/_templates/readme.md.tpl +40 -0
- firecube/ingestor/devtools/scaffolding.py +229 -0
- firecube/ingestor/errors.py +79 -0
- firecube/ingestor/extensions/_binning.py +152 -0
- firecube/ingestor/extensions/duck.py +153 -0
- firecube/ingestor/extensions/grid.py +287 -0
- firecube/ingestor/extensions/healpix.py +330 -0
- firecube/ingestor/registry/__init__.py +14 -0
- firecube/ingestor/registry/loader.py +144 -0
- firecube/ingestor/registry/metadata.py +97 -0
- firecube/ingestor/registry/version_compat.py +95 -0
- firecube/ingestor/runtime/__init__.py +14 -0
- firecube/ingestor/runtime/aggregation.py +112 -0
- firecube/ingestor/runtime/base.py +836 -0
- firecube/ingestor/runtime/base_hooks.py +129 -0
- firecube/ingestor/runtime/batching.py +161 -0
- firecube/ingestor/runtime/configure.py +195 -0
- firecube/ingestor/runtime/coverage.py +177 -0
- firecube/ingestor/runtime/engine.py +793 -0
- firecube/ingestor/runtime/parallel_evidence.py +75 -0
- firecube/ingestor/runtime/parallel_execution_state.py +28 -0
- firecube/ingestor/runtime/parallel_gate.py +164 -0
- firecube/ingestor/runtime/parallel_run_id.py +48 -0
- firecube/ingestor/runtime/preflight.py +40 -0
- firecube/ingestor/runtime/recording.py +425 -0
- firecube/ingestor/runtime/resume_guard.py +556 -0
- firecube/ingestor/runtime/resume_types.py +65 -0
- firecube/ingestor/runtime/telemetry.py +171 -0
- firecube/ingestor/runtime/tensogram/__init__.py +14 -0
- firecube/ingestor/runtime/tensogram/strategy.py +164 -0
- firecube/ingestor/runtime/workspace.py +220 -0
- firecube/ingestor/runtime/zarr/__init__.py +34 -0
- firecube/ingestor/runtime/zarr/append.py +466 -0
- firecube/ingestor/runtime/zarr/append_services.py +650 -0
- firecube/ingestor/runtime/zarr/batch_runner.py +190 -0
- firecube/ingestor/runtime/zarr/contracts.py +58 -0
- firecube/ingestor/runtime/zarr/existing_cube_check.py +282 -0
- firecube/ingestor/runtime/zarr/resume_cache.py +92 -0
- firecube/ingestor/runtime/zarr/staged_metadata.py +249 -0
- firecube/ingestor/runtime/zarr/strategies/__init__.py +15 -0
- firecube/ingestor/runtime/zarr/strategies/append.py +172 -0
- firecube/ingestor/runtime/zarr/strategies/indexed_region.py +426 -0
- firecube/ingestor/runtime/zarr/write.py +212 -0
- firecube/ingestor/runtime/zarr/write_context.py +139 -0
- firecube/ingestor/templates/__init__.py +19 -0
- firecube/ingestor/templates/config.py +118 -0
- firecube/ingestor/templates/direct_zarr.py +790 -0
- firecube/ingestor/templates/generic.py +462 -0
- firecube/ingestor/templates/generic_tensogram.py +170 -0
- firecube/ingestor/types/__init__.py +19 -0
- firecube/ingestor/types/config.py +89 -0
- firecube/ingestor/types/context.py +409 -0
- firecube/ingestor/types/manifest.py +53 -0
- firecube/ingestor/types/planned_range.py +152 -0
- firecube/ingestor/types/result_metrics.py +386 -0
- firecube/ingestor/utils/__init__.py +14 -0
- firecube/ingestor/utils/duckdb_utils.py +235 -0
- firecube/ingestor/validation.py +55 -0
- firecube-0.1.4.dist-info/METADATA +242 -0
- firecube-0.1.4.dist-info/RECORD +197 -0
- firecube-0.1.4.dist-info/WHEEL +4 -0
- firecube-0.1.4.dist-info/entry_points.txt +2 -0
- firecube-0.1.4.dist-info/licenses/AUTHORS.md +5 -0
- 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
|
+
]
|