feat: add perf module — OTel pipeline telemetry with Grafana dashboards

Add `perf` as the 12th skinny package with full OpenTelemetry
instrumentation for pipeline traces, Prometheus metrics, and
Loki-correlated logs.

- src/perf/: TracerProvider, MeterProvider, LoggerProvider bridge,
  PipelineCollector, psutil system metrics, FileExporter fallback,
  FastAPI middleware, auto-file Gitea issues on step/test failure
- infra/otel/: OTel Collector fan-out (traces→Jaeger, metrics→Prometheus,
  logs→Loki), all services migrated from jaeger:4317 to collector
- infra/grafana/dashboards/: 8-panel pipeline performance dashboard
- Pipeline runner auto-instruments all 189 steps with zero-cost
  MockTracer/MockSpan no-ops when telemetry is disabled
- 82 new tests, 12,055 total passing

Closes #203, closes #204, closes #205, closes #206, closes #207,
closes #208, closes #209, closes #210, closes #211, closes #212,
closes #213
This commit is contained in:
kert
2026-03-24 21:37:41 -04:00
parent edcdd643d2
commit 7696c1c6da
34 changed files with 3043 additions and 106 deletions

View File

@@ -254,9 +254,9 @@ services:
- POLARIS_ROOT_SECRET=${POLARIS_ROOT_SECRET}
volumes:
- ./notebooks:/home/kert/notebooks
- ./notebooks/.marimo-config:/home/kert/.config/marimo
- ./infra/marimo:/home/kert/.config/marimo:ro
- ./assets/css/marimo.css:/home/kert/.config/marimo/loch.css:ro
- ./notebooks/home-page-patched.js:/home/kert/workspace/.venv/lib/python3.13/site-packages/marimo/_static/assets/home-page-itW0tRmv.js:ro
- ./infra/marimo/home-page-patched.js:/home/kert/workspace/.venv/lib/python3.13/site-packages/marimo/_static/assets/home-page-itW0tRmv.js:ro
- ./data:/home/kert/data
- ./data/zotero/data:/home/kert/zotero:ro
- ./src:/home/kert/src:ro
@@ -358,7 +358,7 @@ services:
- NESSIE_CATALOG_SERVICE_S3_DEFAULT_OPTIONS_REGION=${S3_REGION:-us-east-1}
# OpenTelemetry tracing
- QUARKUS_OTEL_ENABLED=true
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4317
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
- QUARKUS_OTEL_SERVICE_NAME=nessie
depends_on:
- postgres
@@ -416,7 +416,7 @@ services:
- AWS_REGION=${S3_REGION:-us-east-1}
# OpenTelemetry
- QUARKUS_OTEL_ENABLED=true
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://jaeger:4317
- QUARKUS_OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-collector:4317
- QUARKUS_OTEL_SERVICE_NAME=polaris
depends_on:
- postgres
@@ -468,7 +468,7 @@ services:
- S3_WAREHOUSE=${S3_WAREHOUSE:-s3://lakehouse/}
# OpenTelemetry
- OTEL_SERVICE_NAME=api
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4317
- OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
volumes:
- ./data:/app/data
depends_on:
@@ -534,6 +534,30 @@ services:
- loki
restart: unless-stopped
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
container_name: otel-collector
networks:
- observability
volumes:
- ./infra/otel/otel-collector.yml:/etc/otelcol-contrib/config.yaml:ro
depends_on:
- jaeger
- loki
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://localhost:13133 || exit 1",
]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
security_opt:
- no-new-privileges:true
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
container_name: prometheus

View File

@@ -0,0 +1,217 @@
{
"annotations": {
"list": []
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"links": [],
"panels": [
{
"title": "Pipeline Run Duration",
"type": "timeseries",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "s",
"color": { "mode": "palette-classic" }
},
"overrides": []
},
"targets": [
{
"expr": "histogram_quantile(0.95, sum(rate(stack_stack_pipeline_duration_seconds_bucket{pipeline=~\"$pipeline\"}[$__rate_interval])) by (le, pipeline))",
"legendFormat": "p95 {{pipeline}}",
"refId": "A"
},
{
"expr": "histogram_quantile(0.50, sum(rate(stack_stack_pipeline_duration_seconds_bucket{pipeline=~\"$pipeline\"}[$__rate_interval])) by (le, pipeline))",
"legendFormat": "p50 {{pipeline}}",
"refId": "B"
}
]
},
{
"title": "Step Duration Heatmap",
"type": "heatmap",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [
{
"expr": "sum(rate(stack_stack_step_duration_seconds_bucket{pipeline=~\"$pipeline\"}[$__rate_interval])) by (le, step)",
"legendFormat": "{{step}}",
"refId": "A",
"format": "heatmap"
}
],
"options": {
"calculate": false,
"yAxis": { "unit": "s" }
}
},
{
"title": "Cache Hit Rate",
"type": "gauge",
"gridPos": { "h": 6, "w": 6, "x": 0, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 0.3 },
{ "color": "green", "value": 0.7 }
]
}
},
"overrides": []
},
"targets": [
{
"expr": "sum(rate(stack_stack_cache_hits_total{pipeline=~\"$pipeline\"}[$__rate_interval])) / (sum(rate(stack_stack_cache_hits_total{pipeline=~\"$pipeline\"}[$__rate_interval])) + sum(rate(stack_stack_cache_misses_total{pipeline=~\"$pipeline\"}[$__rate_interval])))",
"legendFormat": "hit rate",
"refId": "A"
}
]
},
{
"title": "Memory RSS",
"type": "timeseries",
"gridPos": { "h": 6, "w": 6, "x": 6, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "decmbytes",
"color": { "mode": "palette-classic" }
},
"overrides": []
},
"targets": [
{
"expr": "stack_stack_system_rss_mb{service=~\"$pipeline\"}",
"legendFormat": "{{service}}",
"refId": "A"
}
]
},
{
"title": "CPU Utilization",
"type": "timeseries",
"gridPos": { "h": 6, "w": 6, "x": 12, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "percent",
"color": { "mode": "palette-classic" }
},
"overrides": []
},
"targets": [
{
"expr": "stack_stack_system_cpu_percent{service=~\"$pipeline\"}",
"legendFormat": "{{service}}",
"refId": "A"
}
]
},
{
"title": "Row Throughput per Step",
"type": "bargauge",
"gridPos": { "h": 6, "w": 6, "x": 18, "y": 8 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"fieldConfig": {
"defaults": {
"unit": "short",
"color": { "mode": "palette-classic" }
},
"overrides": []
},
"targets": [
{
"expr": "sum(rate(stack_stack_step_rows_out_sum{pipeline=~\"$pipeline\"}[$__rate_interval])) by (step)",
"legendFormat": "{{step}}",
"refId": "A"
}
],
"options": {
"orientation": "horizontal",
"displayMode": "gradient",
"showUnfilled": true
}
},
{
"title": "Recent Pipeline Traces",
"type": "table",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 14 },
"datasource": { "type": "jaeger", "uid": "jaeger" },
"targets": [
{
"query": "pipeline.run",
"queryType": "search",
"refId": "A",
"limit": 20,
"service": "stack"
}
],
"options": {
"showHeader": true,
"sortBy": [{ "displayName": "Start Time", "desc": true }]
}
},
{
"title": "Pipeline Logs",
"type": "logs",
"gridPos": { "h": 8, "w": 24, "x": 0, "y": 22 },
"datasource": { "type": "loki", "uid": "loki" },
"targets": [
{
"expr": "{container_name=\"api\"} |= \"trace_id\"",
"refId": "A"
}
],
"options": {
"showTime": true,
"showLabels": false,
"showCommonLabels": false,
"wrapLogMessage": true,
"prettifyLogMessage": false,
"enableLogDetails": true,
"sortOrder": "Descending"
}
}
],
"schemaVersion": 39,
"tags": ["pipeline", "telemetry", "otel"],
"templating": {
"list": [
{
"name": "pipeline",
"type": "query",
"datasource": { "type": "prometheus", "uid": "prometheus" },
"query": "label_values(stack_stack_step_duration_seconds_count, pipeline)",
"refresh": 2,
"includeAll": true,
"multi": true,
"current": {
"selected": true,
"text": "All",
"value": "$__all"
}
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Pipeline Performance",
"uid": "stack-pipeline-perf",
"version": 1
}

View File

@@ -0,0 +1,56 @@
# OpenTelemetry Collector configuration
# Fan-out: traces → Jaeger, metrics → Prometheus scrape, logs → Loki
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
processors:
batch:
timeout: 5s
send_batch_size: 1024
resource:
attributes:
- key: deployment.environment
value: homelab
action: upsert
exporters:
otlp/jaeger:
endpoint: jaeger:4317
tls:
insecure: true
loki:
endpoint: http://loki:3100/loki/api/v1/push
prometheus:
endpoint: 0.0.0.0:8889
namespace: stack
resource_to_telemetry_conversion:
enabled: true
extensions:
health_check:
endpoint: 0.0.0.0:13133
service:
extensions: [health_check]
pipelines:
traces:
receivers: [otlp]
processors: [batch, resource]
exporters: [otlp/jaeger]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [prometheus]
logs:
receivers: [otlp]
processors: [batch, resource]
exporters: [loki]

View File

@@ -35,3 +35,8 @@
labels:
job: trino
__metrics_path__: /v1/status
# OTel Collector
- targets: ['otel-collector:8889']
labels:
job: otel-collector

View File

@@ -37,5 +37,5 @@ experimental:
tracing:
otlp:
grpc:
endpoint: jaeger:4317
endpoint: otel-collector:4317
insecure: true

View File

@@ -6,4 +6,4 @@ discovery.uri=http://localhost:8080
# OpenTelemetry tracing
tracing.enabled=true
tracing.exporter.endpoint=http://jaeger:4317
tracing.exporter.endpoint=http://otel-collector:4317

View File

@@ -70,6 +70,16 @@ rex = [
"pyarrow>=23.0.0",
"fsspec>=2024.1.0",
]
perf = [
"stack[conf]",
"opentelemetry-api>=1.25.0",
"opentelemetry-sdk>=1.25.0",
"opentelemetry-exporter-otlp-proto-grpc>=1.25.0",
"opentelemetry-exporter-prometheus>=0.46b0",
"opentelemetry-instrumentation-fastapi>=0.46b0",
"opentelemetry-semantic-conventions>=0.46b0",
"psutil>=5.9.0",
]
lake = [
"stack[aco]",
"databricks-cli>=0.18.0",
@@ -96,6 +106,7 @@ all = [
"stack[ccw]",
"stack[cli]",
"stack[cms]",
"stack[perf]",
"stack[pfs]",
"stack[rex]",
"stack[lake]",
@@ -135,6 +146,6 @@ ignore = ["E501", "E741"]
testpaths = ["tests"]
[tool.uv.build-backend]
module-name = ["aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "conf", "pfs", "rex"]
module-name = ["aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "conf", "perf", "pfs", "rex"]
namespace = true
source-exclude = ["compose.yml","infra/**","data/**","notebooks/**","tuva/**","assets/**","docs/**","dev/**","bundle/**","cloud/**"]

View File

@@ -135,6 +135,25 @@ def run_pipeline(
cache: dict[str, Any] = {}
new_fingerprints: dict[str, str] = {}
# Optional telemetry — graceful no-op if perf not installed
try:
from perf.collector import PipelineCollector
_perf_available = True
except ImportError:
_perf_available = False
# Optional issue filing on failure
try:
from perf.hooks import on_step_failure
_hooks_available = True
except ImportError:
_hooks_available = False
pipeline_name = _infer_pipeline_name(exprs)
def _run_steps(collector: Any = None) -> None:
for expr in exprs:
if len(expr) == 3:
output_name, fn, output_cls = expr
@@ -154,18 +173,39 @@ def run_pipeline(
kwargs[param] = load(table_ref)
# Idempotency: skip if inputs haven't changed
cache_hit = False
if fingerprints is not None:
fp = _fingerprint_inputs(kwargs, fn)
new_fingerprints[output_name] = fp
if fingerprints.get(output_name) == fp:
# Inputs unchanged — try to load cached output
try:
cache[output_name] = load(output_name)
continue
cache_hit = True
except Exception:
pass # Cache miss — re-execute
pass
if cache_hit:
# Record cache hit in telemetry if available
if collector is not None:
with collector.step(output_name) as s:
s.record(
rows=len(cache[output_name]),
columns=list(cache[output_name].columns),
cache_hit=True,
)
continue
try:
if collector is not None:
with collector.step(output_name) as s:
result = fn(**kwargs)
s.record(
rows=len(result),
columns=list(result.columns),
)
else:
result = fn(**kwargs)
_validate_output(
output_name,
result,
@@ -174,11 +214,21 @@ def run_pipeline(
input_tables=input_tables,
)
cache[output_name] = result
except Exception as exc:
if _hooks_available:
on_step_failure(output_name, exc, pipeline=pipeline_name)
raise
if fingerprints is not None:
fp = _fingerprint_inputs(kwargs, fn)
new_fingerprints[output_name] = fp
if _perf_available:
with PipelineCollector(pipeline_name) as pc:
_run_steps(pc)
else:
_run_steps()
# Attach fingerprints to cache for persistence
if fingerprints is not None:
cache["__fingerprints__"] = new_fingerprints
@@ -186,6 +236,18 @@ def run_pipeline(
return cache
def _infer_pipeline_name(
exprs: list[tuple[str, Callable] | tuple[str, Callable, type | None]],
) -> str:
"""Infer the pipeline name from the first expression's schema prefix."""
if exprs:
name = exprs[0][0] if isinstance(exprs[0][0], str) else "unknown"
if "." in name:
return name.split(".")[0]
return name
return "unknown"
def load_fingerprints(path: str) -> dict[str, str]:
"""Load fingerprints from a JSON file."""
try:

View File

@@ -19,6 +19,16 @@ app = FastAPI(
version="0.1.0",
)
# OTel instrumentation — no-op if perf not installed or telemetry disabled
try:
from perf import init as _perf_init
from perf.middleware import instrument as _perf_instrument
_perf_init()
_perf_instrument(app)
except ImportError:
pass
app.include_router(auth.router)
app.include_router(health.router)
app.include_router(pipelines.router)

View File

@@ -20,6 +20,7 @@ from cli.generate import app as generate_app
from cli.health import health
from cli.lake import app as lake_app
from cli.load import app as load_app
from cli.perf import app as perf_app
from cli.run import run
from cli.validate import validate
@@ -39,6 +40,7 @@ app.add_typer(lake_app, name="lake", help="Lakehouse schema and data.")
app.add_typer(db_app, name="db", help="DuckDB utilities.")
app.add_typer(docs_app, name="docs", help="Documentation generation.")
app.add_typer(api_app, name="api", help="API server.")
app.add_typer(perf_app, name="perf", help="Pipeline telemetry utilities.")
def main() -> None:

62
src/cli/perf.py Normal file
View File

@@ -0,0 +1,62 @@
"""CLI subcommand for pipeline telemetry utilities."""
from __future__ import annotations
import json
from pathlib import Path
import typer
app = typer.Typer(no_args_is_help=True)
@app.command()
def show(
path: str = typer.Option(
"traces/spans.jsonl",
help="Path to JSONL span file.",
),
last: int = typer.Option(
20,
help="Show last N spans.",
),
) -> None:
"""Pretty-print spans from the local JSONL trace file."""
p = Path(path)
if not p.exists():
typer.echo(f"No trace file at {p}")
raise typer.Exit(1)
lines = p.read_text().strip().split("\n")
spans = [json.loads(line) for line in lines[-last:]]
if not spans:
typer.echo("No spans found.")
raise typer.Exit(0)
for span in spans:
trace_id = span.get("trace_id", "?")[:12]
name = span.get("name", "?")
attrs = span.get("attributes", {})
status = span.get("status", {}).get("status_code", "?")
# Duration
start = span.get("start_time", 0)
end = span.get("end_time", 0)
if start and end:
dur_ms = (end - start) / 1_000_000
dur_str = f"{dur_ms:.1f}ms"
else:
dur_str = "?"
# Key attributes
parts = []
if "rows.out" in attrs:
parts.append(f"rows={attrs['rows.out']}")
if "cache.hit" in attrs:
parts.append("cache=HIT" if attrs["cache.hit"] else "cache=MISS")
if "memory.delta_mb" in attrs:
parts.append(f"mem={attrs['memory.delta_mb']:+.1f}MB")
attr_str = " ".join(parts)
typer.echo(f" {trace_id} {name:<40} {dur_str:>10} {status:<4} {attr_str}")

100
src/perf/__init__.py Normal file
View File

@@ -0,0 +1,100 @@
"""Pipeline telemetry — OpenTelemetry traces, metrics, and logs.
Opt-in via ``STACK_TELEMETRY=true`` environment variable or
``[telemetry] enabled = true`` in stack.toml. When disabled, all
public functions return no-op objects with zero overhead.
Quick start::
from perf import init, shutdown
init() # configure providers (reads stack.toml + env)
# ... run pipeline ...
shutdown() # flush pending spans/metrics
For pipeline instrumentation::
from perf import tracer, meter
t = tracer("stack.pipeline")
m = meter("stack.pipeline")
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
pass
# ---------------------------------------------------------------------------
# Lazy state — providers are created once by init()
# ---------------------------------------------------------------------------
_initialised: bool = False
def _enabled() -> bool:
"""Check whether telemetry is enabled via env var or stack.toml."""
env = os.environ.get("STACK_TELEMETRY", "").lower()
if env in ("true", "1"):
return True
if env in ("false", "0"):
return False
# Fall back to stack.toml
try:
from conf import cfg
return bool(cfg.telemetry.enabled)
except (ImportError, AttributeError, KeyError):
return False
def init() -> None:
"""Initialise OpenTelemetry providers.
Safe to call multiple times — only the first call takes effect.
When telemetry is disabled this is a no-op.
"""
global _initialised
if _initialised:
return
_initialised = True
if not _enabled():
return
from perf._meter import setup_meter_provider
from perf._tracer import setup_tracer_provider
setup_tracer_provider()
setup_meter_provider()
def shutdown() -> None:
"""Flush and shut down all providers."""
global _initialised
if not _initialised or not _enabled():
return
from perf._meter import shutdown_meter_provider
from perf._tracer import shutdown_tracer_provider
shutdown_tracer_provider()
shutdown_meter_provider()
_initialised = False
def tracer(name: str) -> Any:
"""Return a tracer (real or mock) for the given instrumentation scope."""
from perf._tracer import create_tracer
return create_tracer(name)
def meter(name: str) -> Any:
"""Return a meter (real or mock) for the given instrumentation scope."""
from perf._meter import create_meter
return create_meter(name)

82
src/perf/_logs.py Normal file
View File

@@ -0,0 +1,82 @@
"""Logging bridge — inject OTel trace context into stdlib log records.
Adds ``trace_id`` and ``span_id`` fields to every log record emitted
while a span is active. Promtail/Loki can extract these as labels
for trace-log correlation in Grafana.
Usage::
from perf._logs import attach_trace_filter
attach_trace_filter() # patches the root logger
# Now every log line includes trace_id and span_id
"""
from __future__ import annotations
import logging
class TraceContextFilter(logging.Filter):
"""Inject OTel trace_id and span_id into log records."""
def filter(self, record: logging.LogRecord) -> bool:
trace_id = "0" * 32
span_id = "0" * 16
try:
from opentelemetry import trace
span = trace.get_current_span()
ctx = span.get_span_context()
if ctx and ctx.trace_id:
trace_id = format(ctx.trace_id, "032x")
span_id = format(ctx.span_id, "016x")
except Exception:
pass
record.trace_id = trace_id # type: ignore[attr-defined]
record.span_id = span_id # type: ignore[attr-defined]
return True
class TraceContextFormatter(logging.Formatter):
"""Formatter that includes trace context for structured log lines."""
DEFAULT_FORMAT = (
"%(asctime)s %(levelname)s [trace_id=%(trace_id)s span_id=%(span_id)s] "
"%(name)s%(message)s"
)
def __init__(self, fmt: str | None = None, **kwargs):
super().__init__(fmt or self.DEFAULT_FORMAT, **kwargs)
_attached: bool = False
def attach_trace_filter(logger: logging.Logger | None = None) -> None:
"""Add the trace context filter to a logger (default: root logger).
Safe to call multiple times — only attaches once per logger.
"""
global _attached
target = logger or logging.getLogger()
# Avoid double-attach
for f in target.filters:
if isinstance(f, TraceContextFilter):
return
target.addFilter(TraceContextFilter())
_attached = True
def detach_trace_filter(logger: logging.Logger | None = None) -> None:
"""Remove the trace context filter from a logger."""
global _attached
target = logger or logging.getLogger()
target.filters = [
f for f in target.filters if not isinstance(f, TraceContextFilter)
]
_attached = False

103
src/perf/_meter.py Normal file
View File

@@ -0,0 +1,103 @@
"""Meter provider setup with MockMeter no-ops.
Mirrors ``_tracer.py`` — when telemetry is disabled, ``create_meter()``
returns a ``MockMeter`` whose instruments are zero-cost no-ops.
"""
from __future__ import annotations
from typing import Any
# ---------------------------------------------------------------------------
# Mock objects
# ---------------------------------------------------------------------------
class _MockInstrument:
"""No-op instrument (counter, histogram, gauge)."""
def add(self, amount: float, attributes: dict | None = None) -> None:
pass
def record(self, amount: float, attributes: dict | None = None) -> None:
pass
def set(self, amount: float, attributes: dict | None = None) -> None:
pass
class MockMeter:
"""No-op meter returned when telemetry is disabled."""
def create_counter(self, name: str, **kw: Any) -> _MockInstrument:
return _MockInstrument()
def create_histogram(self, name: str, **kw: Any) -> _MockInstrument:
return _MockInstrument()
def create_up_down_counter(self, name: str, **kw: Any) -> _MockInstrument:
return _MockInstrument()
def create_observable_gauge(
self, name: str, callbacks: Any = None, **kw: Any
) -> _MockInstrument:
return _MockInstrument()
# ---------------------------------------------------------------------------
# Provider management
# ---------------------------------------------------------------------------
_provider_ready: bool = False
def setup_meter_provider() -> None:
"""Configure the global OTel MeterProvider with Prometheus exporter."""
global _provider_ready
from opentelemetry import metrics
from opentelemetry.sdk.metrics import MeterProvider
from perf._resource import build_resource
resource = build_resource()
readers = []
try:
from opentelemetry.exporter.prometheus import PrometheusMetricReader
reader = PrometheusMetricReader()
readers.append(reader)
except ImportError:
pass
provider = MeterProvider(resource=resource, metric_readers=readers)
metrics.set_meter_provider(provider)
_provider_ready = True
def shutdown_meter_provider() -> None:
"""Flush and shut down the meter provider."""
global _provider_ready
if not _provider_ready:
return
from opentelemetry import metrics
provider = metrics.get_meter_provider()
if hasattr(provider, "shutdown"):
provider.shutdown()
_provider_ready = False
def create_meter(name: str) -> Any:
"""Return a real OTel meter or a MockMeter."""
if not _provider_ready:
from perf import _enabled
if not _enabled():
return MockMeter()
return MockMeter()
from opentelemetry import metrics
return metrics.get_meter(name)

77
src/perf/_resource.py Normal file
View File

@@ -0,0 +1,77 @@
"""Build the OTel Resource shared by all providers.
Includes service name, version, git SHA, and active context.
"""
from __future__ import annotations
import subprocess
def build_resource():
"""Create an OTel Resource with stack-specific attributes."""
from opentelemetry.sdk.resources import Resource
attrs = {
"service.name": _service_name(),
"service.version": _version(),
}
sha = _git_sha()
if sha:
attrs["vcs.revision"] = sha
ctx = _context_name()
if ctx:
attrs["deployment.environment"] = ctx
return Resource.create(attrs)
def _service_name() -> str:
"""Read service name from env or stack.toml."""
import os
env = os.environ.get("OTEL_SERVICE_NAME")
if env:
return env
try:
from conf import cfg
return str(cfg.telemetry.service_name)
except (ImportError, AttributeError, KeyError):
return "stack"
def _version() -> str:
try:
from importlib.metadata import version
return version("stack")
except Exception:
return "0.0.0"
def _git_sha() -> str | None:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return None
def _context_name() -> str | None:
try:
from conf import context
ctx = context()
return str(ctx.db_backend)
except Exception:
return None

48
src/perf/_system.py Normal file
View File

@@ -0,0 +1,48 @@
"""System resource snapshots via psutil.
Captures CPU%, RSS, and disk I/O at a point in time. Used by
``StepCollector`` to measure resource deltas per pipeline step.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class SystemSnapshot:
"""Point-in-time system resource reading."""
rss_mb: float
cpu_percent: float
disk_read_mb: float
disk_write_mb: float
def take_snapshot() -> SystemSnapshot:
"""Capture current process resource usage."""
import psutil
proc = psutil.Process()
mem = proc.memory_info()
io = proc.io_counters()
# cpu_percent with interval=None returns since last call
cpu = proc.cpu_percent(interval=None)
return SystemSnapshot(
rss_mb=mem.rss / (1024 * 1024),
cpu_percent=cpu,
disk_read_mb=io.read_bytes / (1024 * 1024),
disk_write_mb=io.write_bytes / (1024 * 1024),
)
def delta(before: SystemSnapshot, after: SystemSnapshot) -> dict[str, float]:
"""Compute resource deltas between two snapshots."""
return {
"memory.delta_mb": round(after.rss_mb - before.rss_mb, 2),
"memory.rss_mb": round(after.rss_mb, 2),
"cpu_percent": round(after.cpu_percent, 2),
"disk.read_delta_mb": round(after.disk_read_mb - before.disk_read_mb, 2),
"disk.write_delta_mb": round(after.disk_write_mb - before.disk_write_mb, 2),
}

133
src/perf/_tracer.py Normal file
View File

@@ -0,0 +1,133 @@
"""Tracer provider setup with MockTracer/MockSpan no-ops.
Follows the marimo ``_tracer.py`` pattern: when telemetry is disabled,
``create_tracer()`` returns a ``MockTracer`` whose spans are zero-cost
no-ops. Call sites never need conditional checks.
"""
from __future__ import annotations
from contextlib import contextmanager
from typing import Any, Iterator
# ---------------------------------------------------------------------------
# Mock objects — zero overhead when telemetry is off
# ---------------------------------------------------------------------------
class MockSpan:
"""No-op span returned when telemetry is disabled."""
def set_attribute(self, key: str, value: Any) -> None:
pass
def set_status(self, status: Any, description: str | None = None) -> None:
pass
def record_exception(self, exception: BaseException) -> None:
pass
def add_event(self, name: str, attributes: dict | None = None) -> None:
pass
def __enter__(self) -> MockSpan:
return self
def __exit__(self, *args: Any) -> None:
pass
class MockTracer:
"""No-op tracer returned when telemetry is disabled."""
@contextmanager
def start_as_current_span(self, name: str, **kwargs: Any) -> Iterator[MockSpan]:
yield MockSpan()
def start_span(self, name: str, **kwargs: Any) -> MockSpan:
return MockSpan()
# ---------------------------------------------------------------------------
# Provider management
# ---------------------------------------------------------------------------
_provider_ready: bool = False
def setup_tracer_provider() -> None:
"""Configure the global OTel TracerProvider with OTLP exporter."""
global _provider_ready
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from perf._resource import build_resource
resource = build_resource()
provider = TracerProvider(resource=resource)
try:
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
OTLPSpanExporter,
)
endpoint = _get_endpoint()
exporter = OTLPSpanExporter(endpoint=endpoint, insecure=True)
provider.add_span_processor(BatchSpanProcessor(exporter))
except Exception:
# Fall back to file exporter if OTLP is unreachable
from perf.export import FileSpanExporter
provider.add_span_processor(BatchSpanProcessor(FileSpanExporter()))
trace.set_tracer_provider(provider)
_provider_ready = True
def shutdown_tracer_provider() -> None:
"""Flush and shut down the tracer provider."""
global _provider_ready
if not _provider_ready:
return
from opentelemetry import trace
provider = trace.get_tracer_provider()
if hasattr(provider, "shutdown"):
provider.shutdown()
_provider_ready = False
def create_tracer(name: str) -> Any:
"""Return a real OTel tracer or a MockTracer.
If telemetry is enabled and the provider is ready, returns a real
tracer from the global provider. Otherwise returns a MockTracer.
"""
if not _provider_ready:
from perf import _enabled
if not _enabled():
return MockTracer()
# Provider not yet set up — init() hasn't been called
return MockTracer()
from opentelemetry import trace
return trace.get_tracer(name)
def _get_endpoint() -> str:
"""Read the OTLP endpoint from env or stack.toml."""
import os
env = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if env:
return env
try:
from conf import cfg
return str(cfg.telemetry.endpoint)
except (ImportError, AttributeError, KeyError):
return "http://otel-collector:4317"

215
src/perf/collector.py Normal file
View File

@@ -0,0 +1,215 @@
"""StepCollector — context manager that wraps pipeline step execution.
Creates OTel spans and records metrics for each pipeline step::
from perf.collector import PipelineCollector
with PipelineCollector("readmissions") as pc:
for name, fn, output_cls in steps:
with pc.step(name) as s:
result = fn(**args)
s.record(rows=len(result), columns=list(result.columns))
"""
from __future__ import annotations
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Iterator
from perf import meter as get_meter
from perf import tracer as get_tracer
@dataclass
class StepResult:
"""Metrics recorded for a single step execution."""
name: str
rows_out: int = 0
columns: list[str] = field(default_factory=list)
cache_hit: bool = False
duration_s: float = 0.0
system_delta: dict[str, float] = field(default_factory=dict)
class StepContext:
"""Collects metrics during step execution."""
def __init__(self, name: str, span: Any) -> None:
self._name = name
self._span = span
self._result = StepResult(name=name)
def record(
self,
rows: int = 0,
columns: list[str] | None = None,
cache_hit: bool = False,
) -> None:
"""Record step output metrics."""
self._result.rows_out = rows
self._result.columns = columns or []
self._result.cache_hit = cache_hit
self._span.set_attribute("rows.out", rows)
if columns:
self._span.set_attribute("columns.count", len(columns))
self._span.set_attribute("cache.hit", cache_hit)
@property
def result(self) -> StepResult:
return self._result
class PipelineCollector:
"""Wraps an entire pipeline run with tracing and metrics."""
def __init__(self, pipeline_name: str) -> None:
self.pipeline_name = pipeline_name
self._tracer = get_tracer("stack.pipeline")
self._meter = get_meter("stack.pipeline")
self._steps: list[StepResult] = []
self._root_span: Any = None
self._root_ctx: Any = None
# Metrics instruments
self._step_duration = self._meter.create_histogram(
"stack_step_duration_seconds",
unit="s",
description="Duration of a pipeline step",
)
self._pipeline_duration = self._meter.create_histogram(
"stack_pipeline_duration_seconds",
unit="s",
description="Duration of an entire pipeline run",
)
self._rows_out = self._meter.create_histogram(
"stack_step_rows_out",
description="Number of rows output by a step",
)
self._cache_hits = self._meter.create_counter(
"stack_cache_hits_total",
description="Number of cache hits",
)
self._cache_misses = self._meter.create_counter(
"stack_cache_misses_total",
description="Number of cache misses",
)
def __enter__(self) -> PipelineCollector:
self._root_ctx = self._tracer.start_as_current_span(
"pipeline.run",
attributes={"pipeline": self.pipeline_name},
)
self._root_span = self._root_ctx.__enter__()
self._start_time = time.monotonic()
return self
def __exit__(self, *args: Any) -> None:
elapsed = time.monotonic() - self._start_time
self._pipeline_duration.record(elapsed, {"pipeline": self.pipeline_name})
self._root_span.set_attribute("steps.total", len(self._steps))
self._root_span.set_attribute(
"steps.cached", sum(1 for s in self._steps if s.cache_hit)
)
self._root_ctx.__exit__(*args)
@contextmanager
def step(self, name: str) -> Iterator[StepContext]:
"""Wrap a single pipeline step."""
attrs = {"pipeline": self.pipeline_name, "step": name}
with self._tracer.start_as_current_span(
f"step.{name}", attributes=attrs
) as span:
try:
from perf._system import delta, take_snapshot
snap_before = take_snapshot()
except Exception:
snap_before = None
ctx = StepContext(name, span)
t0 = time.monotonic()
try:
yield ctx
finally:
elapsed = time.monotonic() - t0
ctx._result.duration_s = elapsed
# Record system delta
if snap_before is not None:
try:
snap_after = take_snapshot()
sys_delta = delta(snap_before, snap_after)
ctx._result.system_delta = sys_delta
for k, v in sys_delta.items():
span.set_attribute(k, v)
except Exception:
pass
# Record timing
span.set_attribute("duration_s", elapsed)
self._step_duration.record(elapsed, attrs)
# Record row count
if ctx._result.rows_out:
self._rows_out.record(ctx._result.rows_out, attrs)
# Record cache hit/miss
if ctx._result.cache_hit:
self._cache_hits.add(1, attrs)
else:
self._cache_misses.add(1, attrs)
self._steps.append(ctx._result)
@contextmanager
def load(self, table_ref: str) -> Iterator[LoadContext]:
"""Wrap a data load operation as a child span."""
with self._tracer.start_as_current_span(
f"load.{table_ref}",
attributes={"table": table_ref},
) as span:
ctx = LoadContext(table_ref, span)
t0 = time.monotonic()
try:
yield ctx
finally:
elapsed = time.monotonic() - t0
span.set_attribute("duration_s", elapsed)
if ctx.rows:
span.set_attribute("rows", ctx.rows)
@contextmanager
def save(self, table_ref: str) -> Iterator[LoadContext]:
"""Wrap a data save operation as a child span."""
with self._tracer.start_as_current_span(
f"save.{table_ref}",
attributes={"table": table_ref},
) as span:
ctx = LoadContext(table_ref, span)
t0 = time.monotonic()
try:
yield ctx
finally:
elapsed = time.monotonic() - t0
span.set_attribute("duration_s", elapsed)
if ctx.rows:
span.set_attribute("rows", ctx.rows)
class LoadContext:
"""Collects metrics during a load or save operation."""
def __init__(self, table_ref: str, span: Any) -> None:
self.table_ref = table_ref
self.rows: int = 0
self._span = span
def record(self, rows: int = 0) -> None:
self.rows = rows
self._span.set_attribute("rows", rows)

58
src/perf/export.py Normal file
View File

@@ -0,0 +1,58 @@
"""File-based span exporter for local dev without an OTel Collector.
Writes spans as JSONL to a configurable path (default: ``traces/spans.jsonl``).
Inspired by marimo's ``FileExporter`` pattern.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Sequence
def _fallback_path() -> Path:
"""Resolve the fallback JSONL path from stack.toml or default."""
try:
from conf import path as conf_path
return conf_path("telemetry.fallback")
except Exception:
pass
# Default relative to cwd
return Path("traces/spans.jsonl")
class FileSpanExporter:
"""Span exporter that appends JSONL to a local file."""
def __init__(self, path: Path | str | None = None) -> None:
self._path = Path(path) if path else _fallback_path()
def export(self, spans: Sequence) -> int:
"""Write spans as one JSON line each. Returns SUCCESS (0)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
with self._path.open("a", encoding="utf-8") as f:
for span in spans:
ctx = span.get_span_context()
record = {
"trace_id": format(ctx.trace_id, "032x"),
"span_id": format(ctx.span_id, "016x"),
"name": span.name,
"start_time": span.start_time,
"end_time": span.end_time,
"status": {
"status_code": span.status.status_code.name
if span.status
else "UNSET",
},
"attributes": dict(span.attributes) if span.attributes else {},
}
f.write(json.dumps(record) + "\n")
return 0 # SpanExportResult.SUCCESS
def shutdown(self) -> None:
pass
def force_flush(self, timeout_millis: int = 30000) -> bool:
return True

190
src/perf/hooks.py Normal file
View File

@@ -0,0 +1,190 @@
"""Event hooks — auto-file Gitea issues on pipeline and test failures.
Integrates with the existing ``api.diag`` crash diagnostics system
to file issues whenever a pipeline step fails, a step is skipped,
or a test fails or is skipped.
Pipeline integration::
from perf.hooks import on_step_failure, on_step_skip
try:
result = fn(**kwargs)
except Exception as exc:
on_step_failure("readmissions._int_encounter", exc)
raise
Pytest integration::
# In conftest.py
from perf.hooks import pytest_runtest_logreport # noqa: F401
"""
from __future__ import annotations
import logging
import os
import traceback
from typing import Any
log = logging.getLogger(__name__)
_OWNER = "homelab"
_REPO = "stack"
def _get_token() -> str:
"""Resolve Gitea token from env or config."""
token = os.environ.get("GITEA_TOKEN", "")
if token:
return token
try:
from conf import secret
return secret("gitea.token", "GITEA_TOKEN")
except (ImportError, KeyError):
return ""
def _file_issue(title: str, body: str, labels: list[str] | None = None) -> dict | None:
"""Post an issue to Gitea. Returns issue dict or None."""
token = _get_token()
if not token:
log.debug("No GITEA_TOKEN — skipping issue filing")
return None
try:
from api.clients.gitea import GiteaClient
client = GiteaClient(token)
issue_body: dict[str, Any] = {"title": title, "body": body}
if labels:
label_ids = client.resolve_labels(_OWNER, _REPO, labels)
if label_ids:
issue_body["labels"] = label_ids
result = client.create_issue(_OWNER, _REPO, issue_body)
log.info("Filed issue #%s: %s", result.get("number"), title)
client.close()
return result
except Exception:
log.exception("Failed to file Gitea issue")
return None
def _git_sha_short() -> str:
import subprocess
try:
r = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
capture_output=True,
text=True,
timeout=5,
)
return r.stdout.strip() if r.returncode == 0 else "unknown"
except Exception:
return "unknown"
# ── Pipeline hooks ────────────────────────────────────────────────
def on_step_failure(
step_name: str,
exc: BaseException,
*,
pipeline: str = "",
) -> dict | None:
"""File a Gitea issue when a pipeline step fails.
Called from the pipeline runner when a step raises an exception.
"""
sha = _git_sha_short()
exc_str = f"{type(exc).__name__}: {exc}"
if len(exc_str) > 80:
exc_str = exc_str[:77] + "..."
title = f"pipeline/{pipeline or 'unknown'}: step `{step_name}` failed"
tb_lines = traceback.format_exception(type(exc), exc, exc.__traceback__)
tb_text = "".join(tb_lines)
body = (
f"**Pipeline:** `{pipeline}`\n"
f"**Step:** `{step_name}`\n"
f"**Commit:** `{sha}`\n"
f"**Exception:** `{exc_str}`\n\n"
f"## Traceback\n\n"
f"```python\n{tb_text}```\n"
)
return _file_issue(title, body, labels=["bug", "pipeline"])
def on_step_skip(
step_name: str,
*,
pipeline: str = "",
reason: str = "fingerprint cache hit",
) -> dict | None:
"""File a Gitea issue when a pipeline step is unexpectedly skipped."""
sha = _git_sha_short()
title = f"pipeline/{pipeline or 'unknown'}: step `{step_name}` skipped"
body = (
f"**Pipeline:** `{pipeline}`\n"
f"**Step:** `{step_name}`\n"
f"**Commit:** `{sha}`\n"
f"**Reason:** {reason}\n\n"
f"This step was skipped during pipeline execution. "
f"If this is unexpected, check input data and fingerprint cache.\n"
)
return _file_issue(title, body, labels=["pipeline"])
# ── Pytest plugin ─────────────────────────────────────────────────
# This module is registered as a pytest plugin via conftest or
# pyproject.toml's [tool.pytest.ini_options] markers.
def pytest_runtest_logreport(report):
"""Pytest hook — file a Gitea issue on test failure or unexpected skip.
Only fires when STACK_FILE_TEST_ISSUES=true is set (CI environments).
"""
# Only act on test call phase
if report.when != "call":
return
# Only in CI — don't spam during local dev
if not os.environ.get("STACK_FILE_TEST_ISSUES", ""):
return
if report.failed:
_file_test_issue(report, "failed")
elif report.skipped:
_file_test_issue(report, "skipped")
def _file_test_issue(report, status: str) -> dict | None:
"""Build and file a Gitea issue for a test failure or skip."""
sha = _git_sha_short()
test_id = report.nodeid
title = f"test {status}: `{test_id}`"
body_parts = [
f"**Test:** `{test_id}`",
f"**Status:** `{status}`",
f"**Commit:** `{sha}`",
"",
]
if report.longrepr:
body_parts.append("## Output\n")
body_parts.append(f"```\n{report.longreprtext}\n```\n")
body = "\n".join(body_parts)
labels = ["bug", "test"] if status == "failed" else ["test"]
return _file_issue(title, body, labels=labels)

31
src/perf/middleware.py Normal file
View File

@@ -0,0 +1,31 @@
"""FastAPI OpenTelemetry middleware.
One-liner instrumentation for the stack API::
from perf.middleware import instrument
instrument(app)
"""
from __future__ import annotations
from typing import Any
def instrument(app: Any) -> None:
"""Add OTel instrumentation to a FastAPI application.
- Auto-creates spans for all HTTP requests
- Propagates trace context into background pipeline runs
- No-op if telemetry is disabled or OTel packages unavailable
"""
from perf import _enabled
if not _enabled():
return
try:
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
FastAPIInstrumentor.instrument_app(app)
except Exception:
pass

View File

@@ -188,6 +188,13 @@ registry_secret = "REGISTRY_TOKEN"
uv_version = "latest"
ci_registry = "gitea:3000" # internal HTTP endpoint (avoids self-signed TLS)
[telemetry]
enabled = false
endpoint = "http://otel-collector:4317"
fallback = "traces/spans.jsonl"
sample_rate = 1.0
service_name = "stack"
[lint]
line_length = 88
@@ -206,7 +213,7 @@ trivy_exit_code = 0
[images.notebooks]
dockerfile = "infra/images/notebooks.Dockerfile"
context = "notebooks/"
path_filter = ["notebooks/**", "infra/images/notebooks.Dockerfile"]
path_filter = ["notebooks/**", "infra/marimo/**", "infra/images/notebooks.Dockerfile"]
[images.zotero]
dockerfile = "infra/images/zotero.Dockerfile"

View File

@@ -16,6 +16,14 @@ from pathlib import Path
import polars as pl
import pytest
# ── Perf hooks — auto-file Gitea issues on test failure/skip in CI ───────────
# Only active when STACK_FILE_TEST_ISSUES=true (set in CI workflows).
try:
from perf.hooks import pytest_runtest_logreport # noqa: F401
except ImportError:
pass
# ── input_layer fixtures ──────────────────────────────────────────────────────

0
tests/perf/__init__.py Normal file
View File

View File

@@ -0,0 +1,187 @@
"""Tests for StepCollector, PipelineCollector, and system snapshots."""
from __future__ import annotations
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
SimpleSpanProcessor,
SpanExporter,
SpanExportResult,
)
class _MemoryExporter(SpanExporter):
def __init__(self):
self.spans = []
def export(self, spans):
self.spans.extend(spans)
return SpanExportResult.SUCCESS
def shutdown(self):
pass
@pytest.fixture()
def trace_setup(monkeypatch):
"""Wire up real tracer + mock telemetry enabled."""
monkeypatch.setenv("STACK_TELEMETRY", "false")
original = trace.get_tracer_provider()
exporter = _MemoryExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
# Make perf.tracer() return real tracers from this provider
import perf._tracer as tmod
tmod._provider_ready = True
yield exporter
tmod._provider_ready = False
provider.shutdown()
trace._TRACER_PROVIDER = original
trace._TRACER_PROVIDER_SET_ONCE._done = False
class TestSystemSnapshot:
"""psutil-based system snapshots."""
def test_take_snapshot(self):
from perf._system import take_snapshot
snap = take_snapshot()
assert snap.rss_mb > 0
assert snap.disk_read_mb >= 0
assert snap.disk_write_mb >= 0
def test_delta(self):
from perf._system import SystemSnapshot, delta
before = SystemSnapshot(
rss_mb=100.0,
cpu_percent=10.0,
disk_read_mb=50.0,
disk_write_mb=20.0,
)
after = SystemSnapshot(
rss_mb=120.0,
cpu_percent=25.0,
disk_read_mb=55.0,
disk_write_mb=22.0,
)
d = delta(before, after)
assert d["memory.delta_mb"] == 20.0
assert d["memory.rss_mb"] == 120.0
assert d["cpu_percent"] == 25.0
assert d["disk.read_delta_mb"] == 5.0
assert d["disk.write_delta_mb"] == 2.0
class TestPipelineCollector:
"""PipelineCollector creates proper span hierarchy."""
def test_step_creates_span(self, trace_setup):
exporter = trace_setup
from perf.collector import PipelineCollector
with PipelineCollector("test_pipeline") as pc:
with pc.step("step_a") as s:
s.record(rows=42, columns=["a", "b"])
names = [s.name for s in exporter.spans]
assert "step.step_a" in names
assert "pipeline.run" in names
def test_step_records_attributes(self, trace_setup):
exporter = trace_setup
from perf.collector import PipelineCollector
with PipelineCollector("test_pipeline") as pc:
with pc.step("step_b") as s:
s.record(rows=100, columns=["x", "y", "z"], cache_hit=True)
step_span = next(s for s in exporter.spans if s.name == "step.step_b")
assert step_span.attributes["rows.out"] == 100
assert step_span.attributes["columns.count"] == 3
assert step_span.attributes["cache.hit"] is True
def test_multiple_steps(self, trace_setup):
exporter = trace_setup
from perf.collector import PipelineCollector
with PipelineCollector("multi") as pc:
with pc.step("first") as s:
s.record(rows=10)
with pc.step("second") as s:
s.record(rows=20)
with pc.step("third") as s:
s.record(rows=30)
root = next(s for s in exporter.spans if s.name == "pipeline.run")
assert root.attributes["steps.total"] == 3
def test_step_timing(self, trace_setup):
import time
from perf.collector import PipelineCollector
with PipelineCollector("timed") as pc:
with pc.step("slow") as s:
time.sleep(0.01)
s.record(rows=1)
assert s.result.duration_s >= 0.01
def test_step_system_delta(self, trace_setup):
from perf.collector import PipelineCollector
with PipelineCollector("sys") as pc:
with pc.step("mem_test") as s:
# Allocate some memory to create a delta
_data = bytearray(1024 * 1024) # 1MB
s.record(rows=1)
assert "memory.rss_mb" in s.result.system_delta
def test_cache_hit_flag(self, trace_setup):
from perf.collector import PipelineCollector
with PipelineCollector("cache_test") as pc:
with pc.step("cached") as s:
s.record(rows=50, cache_hit=True)
with pc.step("uncached") as s2:
s2.record(rows=50, cache_hit=False)
root = next(s for s in trace_setup.spans if s.name == "pipeline.run")
assert root.attributes["steps.cached"] == 1
def test_load_span(self, trace_setup):
exporter = trace_setup
from perf.collector import PipelineCollector
with PipelineCollector("load_test") as pc:
with pc.step("step_load") as s:
with pc.load("core.encounter") as lc:
lc.record(rows=500)
s.record(rows=500)
load_span = next(s for s in exporter.spans if s.name == "load.core.encounter")
assert load_span.attributes["rows"] == 500
assert load_span.attributes["table"] == "core.encounter"
def test_save_span(self, trace_setup):
exporter = trace_setup
from perf.collector import PipelineCollector
with PipelineCollector("save_test") as pc:
with pc.step("step_save") as s:
s.record(rows=200)
with pc.save("readmissions.output") as sc:
sc.record(rows=200)
save_span = next(
s for s in exporter.spans if s.name == "save.readmissions.output"
)
assert save_span.attributes["rows"] == 200

View File

@@ -0,0 +1,69 @@
"""Tests for the Grafana pipeline performance dashboard JSON."""
from __future__ import annotations
import json
from pathlib import Path
DASHBOARD_PATH = (
Path(__file__).resolve().parents[2]
/ "infra"
/ "grafana"
/ "dashboards"
/ "pipeline-performance.json"
)
class TestDashboard:
def test_file_exists(self):
assert DASHBOARD_PATH.exists()
def test_valid_json(self):
data = json.loads(DASHBOARD_PATH.read_text())
assert isinstance(data, dict)
def test_has_required_keys(self):
data = json.loads(DASHBOARD_PATH.read_text())
assert "panels" in data
assert "title" in data
assert "uid" in data
assert "templating" in data
def test_uid(self):
data = json.loads(DASHBOARD_PATH.read_text())
assert data["uid"] == "stack-pipeline-perf"
def test_has_panels(self):
data = json.loads(DASHBOARD_PATH.read_text())
panels = data["panels"]
assert len(panels) >= 8
titles = {p["title"] for p in panels}
assert "Pipeline Run Duration" in titles
assert "Cache Hit Rate" in titles
assert "Memory RSS" in titles
assert "CPU Utilization" in titles
assert "Row Throughput per Step" in titles
assert "Recent Pipeline Traces" in titles
assert "Pipeline Logs" in titles
def test_has_pipeline_variable(self):
data = json.loads(DASHBOARD_PATH.read_text())
variables = data["templating"]["list"]
names = [v["name"] for v in variables]
assert "pipeline" in names
def test_datasources(self):
data = json.loads(DASHBOARD_PATH.read_text())
ds_types = set()
for panel in data["panels"]:
if "datasource" in panel:
ds_types.add(panel["datasource"]["type"])
assert "prometheus" in ds_types
assert "jaeger" in ds_types
assert "loki" in ds_types
def test_tags(self):
data = json.loads(DASHBOARD_PATH.read_text())
assert "pipeline" in data.get("tags", [])
assert "otel" in data.get("tags", [])

99
tests/perf/test_export.py Normal file
View File

@@ -0,0 +1,99 @@
"""Tests for FileSpanExporter fallback and CLI show command."""
from __future__ import annotations
import json
from unittest.mock import MagicMock
from perf.export import FileSpanExporter
def _mock_span(name="test", trace_id=0xABCD, span_id=0x1234, attrs=None):
ctx = MagicMock()
ctx.trace_id = trace_id
ctx.span_id = span_id
status = MagicMock()
status.status_code.name = "OK"
span = MagicMock()
span.get_span_context.return_value = ctx
span.name = name
span.start_time = 1_000_000_000
span.end_time = 2_000_000_000
span.status = status
span.attributes = attrs or {}
return span
class TestFileExporter:
def test_creates_directory(self, tmp_path):
out = tmp_path / "deep" / "dir" / "spans.jsonl"
exporter = FileSpanExporter(path=out)
exporter.export([_mock_span()])
assert out.exists()
def test_appends_multiple_exports(self, tmp_path):
out = tmp_path / "spans.jsonl"
exporter = FileSpanExporter(path=out)
exporter.export([_mock_span("a")])
exporter.export([_mock_span("b"), _mock_span("c")])
lines = out.read_text().strip().split("\n")
assert len(lines) == 3
names = [json.loads(l)["name"] for l in lines]
assert names == ["a", "b", "c"]
def test_attributes_preserved(self, tmp_path):
out = tmp_path / "spans.jsonl"
exporter = FileSpanExporter(path=out)
exporter.export([_mock_span(attrs={"rows.out": 42, "cache.hit": True})])
record = json.loads(out.read_text().strip())
assert record["attributes"]["rows.out"] == 42
assert record["attributes"]["cache.hit"] is True
def test_shutdown_noop(self, tmp_path):
exporter = FileSpanExporter(path=tmp_path / "spans.jsonl")
exporter.shutdown() # should not raise
def test_force_flush(self, tmp_path):
exporter = FileSpanExporter(path=tmp_path / "spans.jsonl")
assert exporter.force_flush() is True
class TestCLIShow:
def test_show_with_data(self, tmp_path):
from typer.testing import CliRunner
from cli.perf import app
runner = CliRunner()
trace_file = tmp_path / "spans.jsonl"
exporter = FileSpanExporter(path=trace_file)
exporter.export(
[
_mock_span("pipeline.run", attrs={"rows.out": 100}),
_mock_span(
"step.core.encounter",
attrs={
"rows.out": 50,
"cache.hit": False,
"memory.delta_mb": 2.5,
},
),
]
)
result = runner.invoke(app, ["--path", str(trace_file)])
assert result.exit_code == 0
assert "pipeline.run" in result.output
assert "step.core.encounter" in result.output
assert "rows=50" in result.output
def test_show_missing_file(self, tmp_path):
from typer.testing import CliRunner
from cli.perf import app
runner = CliRunner()
result = runner.invoke(app, ["--path", str(tmp_path / "nope.jsonl")])
assert result.exit_code == 1

174
tests/perf/test_hooks.py Normal file
View File

@@ -0,0 +1,174 @@
"""Tests for perf.hooks — Gitea issue filing on failure/skip."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
class TestOnStepFailure:
"""on_step_failure builds correct issue title and body."""
@patch("perf.hooks._file_issue")
def test_files_issue_on_exception(self, mock_file):
mock_file.return_value = {"number": 999}
from perf.hooks import on_step_failure
exc = ValueError("missing column: person_id")
on_step_failure("core.encounter", exc, pipeline="readmissions")
mock_file.assert_called_once()
title, body = mock_file.call_args[0][:2]
assert "core.encounter" in title
assert "readmissions" in title
assert "failed" in title
assert "ValueError" in body
assert "missing column: person_id" in body
assert "Traceback" in body
@patch("perf.hooks._file_issue")
def test_labels_include_bug_and_pipeline(self, mock_file):
mock_file.return_value = {"number": 1}
from perf.hooks import on_step_failure
on_step_failure("x.y", RuntimeError("boom"), pipeline="core")
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
assert "bug" in labels
assert "pipeline" in labels
class TestOnStepSkip:
"""on_step_skip builds correct issue for skipped steps."""
@patch("perf.hooks._file_issue")
def test_files_issue_on_skip(self, mock_file):
mock_file.return_value = {"number": 100}
from perf.hooks import on_step_skip
on_step_skip("readmissions._int_enc", pipeline="readmissions")
mock_file.assert_called_once()
title = mock_file.call_args[0][0]
assert "skipped" in title
assert "readmissions._int_enc" in title
class TestPytestHook:
"""pytest_runtest_logreport files issues in CI."""
@patch("perf.hooks._file_test_issue")
def test_ignores_setup_phase(self, mock_file):
from perf.hooks import pytest_runtest_logreport
report = MagicMock()
report.when = "setup"
pytest_runtest_logreport(report)
mock_file.assert_not_called()
@patch("perf.hooks._file_test_issue")
def test_ignores_without_env_var(self, mock_file, monkeypatch):
monkeypatch.delenv("STACK_FILE_TEST_ISSUES", raising=False)
from perf.hooks import pytest_runtest_logreport
report = MagicMock()
report.when = "call"
report.failed = True
pytest_runtest_logreport(report)
mock_file.assert_not_called()
@patch("perf.hooks._file_test_issue")
def test_files_on_failure_in_ci(self, mock_file, monkeypatch):
monkeypatch.setenv("STACK_FILE_TEST_ISSUES", "true")
from perf.hooks import pytest_runtest_logreport
report = MagicMock()
report.when = "call"
report.failed = True
report.skipped = False
pytest_runtest_logreport(report)
mock_file.assert_called_once_with(report, "failed")
@patch("perf.hooks._file_test_issue")
def test_files_on_skip_in_ci(self, mock_file, monkeypatch):
monkeypatch.setenv("STACK_FILE_TEST_ISSUES", "true")
from perf.hooks import pytest_runtest_logreport
report = MagicMock()
report.when = "call"
report.failed = False
report.skipped = True
pytest_runtest_logreport(report)
mock_file.assert_called_once_with(report, "skipped")
class TestFileTestIssue:
"""_file_test_issue builds correct markdown body."""
@patch("perf.hooks._file_issue")
def test_body_includes_nodeid(self, mock_file):
mock_file.return_value = {"number": 50}
from perf.hooks import _file_test_issue
report = MagicMock()
report.nodeid = "tests/aco/test_core.py::test_encounter"
report.longrepr = "AssertionError: ..."
report.longreprtext = "AssertionError: expected 10 got 0"
_file_test_issue(report, "failed")
title, body = mock_file.call_args[0][:2]
assert "test_core.py::test_encounter" in title
assert "failed" in title
assert "AssertionError" in body
@patch("perf.hooks._file_issue")
def test_failed_gets_bug_label(self, mock_file):
mock_file.return_value = {"number": 51}
from perf.hooks import _file_test_issue
report = MagicMock()
report.nodeid = "tests/foo.py::test_bar"
report.longrepr = None
_file_test_issue(report, "failed")
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
assert "bug" in labels
assert "test" in labels
@patch("perf.hooks._file_issue")
def test_skipped_no_bug_label(self, mock_file):
mock_file.return_value = {"number": 52}
from perf.hooks import _file_test_issue
report = MagicMock()
report.nodeid = "tests/foo.py::test_baz"
report.longrepr = None
_file_test_issue(report, "skipped")
labels = mock_file.call_args[1].get("labels") or mock_file.call_args[0][2]
assert "bug" not in labels
assert "test" in labels
class TestRunnerIntegration:
"""Pipeline runner calls on_step_failure when a step raises."""
@patch("perf.hooks.on_step_failure")
def test_runner_fires_hook_on_failure(self, mock_hook):
import polars as pl
from aco.pipe.runner import run_pipeline
def bad_step(input_layer__data):
raise RuntimeError("data corrupted")
data = pl.DataFrame({"id": [1]})
try:
run_pipeline([("ns.bad", bad_step)], lambda ref: data)
except RuntimeError:
pass
mock_hook.assert_called_once()
args = mock_hook.call_args
assert args[0][0] == "ns.bad"
assert isinstance(args[0][1], RuntimeError)

201
tests/perf/test_init.py Normal file
View File

@@ -0,0 +1,201 @@
"""Tests for the perf package scaffold — public API, mock objects, config."""
from __future__ import annotations
class TestEnabled:
"""perf._enabled() resolves from env var and stack.toml."""
def test_env_true(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "true")
from perf import _enabled
assert _enabled() is True
def test_env_false(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "false")
from perf import _enabled
assert _enabled() is False
def test_env_1(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "1")
from perf import _enabled
assert _enabled() is True
def test_env_0(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "0")
from perf import _enabled
assert _enabled() is False
def test_fallback_to_config(self, monkeypatch):
monkeypatch.delenv("STACK_TELEMETRY", raising=False)
from perf import _enabled
# stack.toml has enabled = false by default
assert _enabled() is False
class TestMockTracer:
"""MockTracer and MockSpan are zero-cost no-ops."""
def test_mock_span_context_manager(self):
from perf._tracer import MockSpan
span = MockSpan()
with span as s:
s.set_attribute("key", "value")
s.set_status("OK")
s.record_exception(ValueError("test"))
s.add_event("test_event", {"k": "v"})
def test_mock_tracer_start_as_current_span(self):
from perf._tracer import MockTracer
tracer = MockTracer()
with tracer.start_as_current_span("test_span") as span:
span.set_attribute("rows.out", 100)
def test_mock_tracer_start_span(self):
from perf._tracer import MockTracer
tracer = MockTracer()
span = tracer.start_span("test_span")
assert span is not None
class TestMockMeter:
"""MockMeter instruments are zero-cost no-ops."""
def test_mock_counter(self):
from perf._meter import MockMeter
meter = MockMeter()
counter = meter.create_counter("test_counter")
counter.add(1, {"pipeline": "test"})
def test_mock_histogram(self):
from perf._meter import MockMeter
meter = MockMeter()
hist = meter.create_histogram("test_histogram")
hist.record(0.5, {"step": "test"})
def test_mock_up_down_counter(self):
from perf._meter import MockMeter
meter = MockMeter()
udc = meter.create_up_down_counter("test_udc")
udc.add(-1)
def test_mock_observable_gauge(self):
from perf._meter import MockMeter
meter = MockMeter()
gauge = meter.create_observable_gauge("test_gauge")
gauge.set(42.0)
class TestPublicAPI:
"""perf.tracer() and perf.meter() return mocks when disabled."""
def test_tracer_returns_mock_when_disabled(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "false")
from perf import tracer
from perf._tracer import MockTracer
t = tracer("test.scope")
assert isinstance(t, MockTracer)
def test_meter_returns_mock_when_disabled(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "false")
from perf import meter
from perf._meter import MockMeter
m = meter("test.scope")
assert isinstance(m, MockMeter)
def test_init_noop_when_disabled(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "false")
import perf
perf._initialised = False # reset state
perf.init()
# Should not raise — just a no-op
perf.shutdown()
class TestFileSpanExporter:
"""FileSpanExporter writes JSONL to disk."""
def test_export_writes_jsonl(self, tmp_path):
import json
from unittest.mock import MagicMock
from perf.export import FileSpanExporter
out = tmp_path / "spans.jsonl"
exporter = FileSpanExporter(path=out)
# Build a mock span
mock_ctx = MagicMock()
mock_ctx.trace_id = 0x1234567890ABCDEF1234567890ABCDEF
mock_ctx.span_id = 0xABCDEF1234567890
mock_status = MagicMock()
mock_status.status_code.name = "OK"
mock_span = MagicMock()
mock_span.get_span_context.return_value = mock_ctx
mock_span.name = "test.step"
mock_span.start_time = 1000000000
mock_span.end_time = 2000000000
mock_span.status = mock_status
mock_span.attributes = {"rows.out": 42}
result = exporter.export([mock_span])
assert result == 0
lines = out.read_text().strip().split("\n")
assert len(lines) == 1
record = json.loads(lines[0])
assert record["name"] == "test.step"
assert record["attributes"]["rows.out"] == 42
assert len(record["trace_id"]) == 32
assert len(record["span_id"]) == 16
class TestResource:
"""_resource.py builds OTel-compatible resource attributes."""
def test_service_name_from_env(self, monkeypatch):
monkeypatch.setenv("OTEL_SERVICE_NAME", "my-service")
from perf._resource import _service_name
assert _service_name() == "my-service"
def test_service_name_default(self, monkeypatch):
monkeypatch.delenv("OTEL_SERVICE_NAME", raising=False)
from perf._resource import _service_name
# Falls back to stack.toml or "stack"
name = _service_name()
assert isinstance(name, str)
assert len(name) > 0
def test_git_sha(self):
from perf._resource import _git_sha
sha = _git_sha()
# We're in a git repo, so this should return a short SHA
assert sha is not None
assert len(sha) >= 7
def test_version(self):
from perf._resource import _version
v = _version()
assert isinstance(v, str)

View File

@@ -0,0 +1,149 @@
"""Integration tests — runner with perf collector, middleware wiring."""
from __future__ import annotations
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
SimpleSpanProcessor,
SpanExporter,
SpanExportResult,
)
class _MemoryExporter(SpanExporter):
def __init__(self):
self.spans = []
def export(self, spans):
self.spans.extend(spans)
return SpanExportResult.SUCCESS
def shutdown(self):
pass
@pytest.fixture()
def trace_setup(monkeypatch):
"""Wire up real tracer for integration tests."""
monkeypatch.setenv("STACK_TELEMETRY", "false")
original = trace.get_tracer_provider()
exporter = _MemoryExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
import perf._tracer as tmod
tmod._provider_ready = True
yield exporter
tmod._provider_ready = False
provider.shutdown()
trace._TRACER_PROVIDER = original
trace._TRACER_PROVIDER_SET_ONCE._done = False
class TestRunnerIntegration:
"""run_pipeline emits spans via PipelineCollector."""
def test_pipeline_creates_spans(self, trace_setup):
import polars as pl
from aco.pipe.runner import run_pipeline
# Simple two-step pipeline
def step_a(input_layer__eligibility):
return input_layer__eligibility.select("person_id")
def step_b(core__step_a):
return core__step_a
eligibility = pl.DataFrame({"person_id": ["P001", "P002"]})
def load(ref):
if ref == "input_layer.eligibility":
return eligibility
raise KeyError(ref)
cache = run_pipeline(
[
("core.step_a", step_a),
("core.step_b", step_b),
],
load,
)
assert "core.step_a" in cache
assert "core.step_b" in cache
assert len(cache["core.step_a"]) == 2
# Verify spans were created
names = [s.name for s in trace_setup.spans]
assert "pipeline.run" in names
assert "step.core.step_a" in names
assert "step.core.step_b" in names
def test_pipeline_records_row_counts(self, trace_setup):
import polars as pl
from aco.pipe.runner import run_pipeline
def step_one(input_layer__data):
return input_layer__data
data = pl.DataFrame({"id": list(range(50))})
run_pipeline(
[("core.step_one", step_one)],
lambda ref: data,
)
step_span = next(s for s in trace_setup.spans if s.name == "step.core.step_one")
assert step_span.attributes["rows.out"] == 50
def test_pipeline_without_perf_still_works(self, monkeypatch):
"""Verify graceful degradation if perf collector import fails."""
import polars as pl
from aco.pipe.runner import run_pipeline
def step_x(input_layer__table):
return input_layer__table
data = pl.DataFrame({"col": [1, 2, 3]})
cache = run_pipeline(
[("ns.step_x", step_x)],
lambda ref: data,
)
assert len(cache["ns.step_x"]) == 3
def test_pipeline_name_inference(self):
from aco.pipe.runner import _infer_pipeline_name
assert (
_infer_pipeline_name([("readmissions._int_enc", lambda: None)])
== "readmissions"
)
assert _infer_pipeline_name([("core.encounter", lambda: None)]) == "core"
assert _infer_pipeline_name([]) == "unknown"
class TestMiddleware:
"""perf.middleware.instrument is a safe no-op when disabled."""
def test_instrument_noop_when_disabled(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "false")
from unittest.mock import MagicMock
from perf.middleware import instrument
app = MagicMock()
instrument(app) # should not raise
def test_server_import(self):
"""Verify server.py imports cleanly with perf wiring."""
from api.server import app
assert app is not None
assert app.title == "stack"

110
tests/perf/test_logs.py Normal file
View File

@@ -0,0 +1,110 @@
"""Tests for the logging bridge — trace context injection."""
from __future__ import annotations
import logging
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
SimpleSpanProcessor,
SpanExporter,
SpanExportResult,
)
from perf._logs import (
TraceContextFilter,
TraceContextFormatter,
attach_trace_filter,
detach_trace_filter,
)
class _NoopExporter(SpanExporter):
def export(self, spans):
return SpanExportResult.SUCCESS
def shutdown(self):
pass
@pytest.fixture()
def otel_provider():
"""Set up a real tracer provider for span context tests."""
original = trace.get_tracer_provider()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(_NoopExporter()))
trace.set_tracer_provider(provider)
yield provider
provider.shutdown()
trace._TRACER_PROVIDER = original
trace._TRACER_PROVIDER_SET_ONCE._done = False
class TestTraceContextFilter:
"""TraceContextFilter injects trace_id and span_id."""
def test_zero_ids_outside_span(self):
filt = TraceContextFilter()
record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)
result = filt.filter(record)
assert result is True
assert record.trace_id == "0" * 32 # type: ignore[attr-defined]
assert record.span_id == "0" * 16 # type: ignore[attr-defined]
def test_real_ids_inside_span(self, otel_provider):
filt = TraceContextFilter()
tracer = trace.get_tracer("test")
with tracer.start_as_current_span("log.test"):
record = logging.LogRecord("test", logging.INFO, "", 0, "msg", (), None)
filt.filter(record)
assert record.trace_id != "0" * 32 # type: ignore[attr-defined]
assert record.span_id != "0" * 16 # type: ignore[attr-defined]
assert len(record.trace_id) == 32 # type: ignore[attr-defined]
assert len(record.span_id) == 16 # type: ignore[attr-defined]
class TestAttachDetach:
"""attach_trace_filter and detach_trace_filter manage the root logger."""
def test_attach_adds_filter(self):
logger = logging.getLogger("test.attach")
attach_trace_filter(logger)
assert any(isinstance(f, TraceContextFilter) for f in logger.filters)
detach_trace_filter(logger)
def test_detach_removes_filter(self):
logger = logging.getLogger("test.detach")
attach_trace_filter(logger)
detach_trace_filter(logger)
assert not any(isinstance(f, TraceContextFilter) for f in logger.filters)
def test_double_attach_is_idempotent(self):
logger = logging.getLogger("test.double")
attach_trace_filter(logger)
attach_trace_filter(logger)
count = sum(1 for f in logger.filters if isinstance(f, TraceContextFilter))
assert count == 1
detach_trace_filter(logger)
class TestTraceContextFormatter:
"""Formatter includes trace context fields in output."""
def test_format_includes_trace_id(self, otel_provider):
formatter = TraceContextFormatter()
filt = TraceContextFilter()
tracer = trace.get_tracer("test")
with tracer.start_as_current_span("fmt.test"):
record = logging.LogRecord(
"test.logger", logging.INFO, "", 0, "hello world", (), None
)
filt.filter(record)
output = formatter.format(record)
assert "trace_id=" in output
assert "span_id=" in output
assert "hello world" in output
# Should NOT contain all-zero IDs
assert "trace_id=" + "0" * 32 not in output

103
tests/perf/test_meter.py Normal file
View File

@@ -0,0 +1,103 @@
"""Tests for real OTel MeterProvider setup and metric instruments."""
from __future__ import annotations
import pytest
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import InMemoryMetricReader
@pytest.fixture()
def meter_setup():
"""Create a fresh MeterProvider + reader pair (no global mutation)."""
reader = InMemoryMetricReader()
provider = MeterProvider(metric_readers=[reader])
yield provider, reader
provider.shutdown()
class TestRealMetrics:
"""Verify metric instrument creation and recording."""
def test_counter(self, meter_setup):
provider, reader = meter_setup
meter = provider.get_meter("test.pipeline")
counter = meter.create_counter(
"stack_cache_hits_total",
description="Cache hits",
)
counter.add(1, {"pipeline": "readmissions"})
counter.add(3, {"pipeline": "readmissions"})
data = reader.get_metrics_data()
rm = data.resource_metrics
assert len(rm) > 0
sm = rm[0].scope_metrics
assert len(sm) > 0
metric = sm[0].metrics[0]
assert metric.name == "stack_cache_hits_total"
dp = metric.data.data_points[0]
assert dp.value == 4
def test_histogram(self, meter_setup):
provider, reader = meter_setup
meter = provider.get_meter("test.pipeline")
hist = meter.create_histogram(
"stack_step_duration_seconds",
unit="s",
description="Step duration",
)
hist.record(0.5, {"pipeline": "core", "step": "encounter"})
hist.record(1.2, {"pipeline": "core", "step": "encounter"})
data = reader.get_metrics_data()
metric = data.resource_metrics[0].scope_metrics[0].metrics[0]
assert metric.name == "stack_step_duration_seconds"
dp = metric.data.data_points[0]
assert dp.count == 2
assert dp.sum == pytest.approx(1.7)
def test_gauge_via_up_down_counter(self, meter_setup):
provider, reader = meter_setup
meter = provider.get_meter("test.pipeline")
gauge = meter.create_up_down_counter(
"stack_step_rows_out",
description="Rows output by step",
)
gauge.add(100, {"step": "core.encounter"})
data = reader.get_metrics_data()
metric = data.resource_metrics[0].scope_metrics[0].metrics[0]
assert metric.name == "stack_step_rows_out"
def test_multiple_instruments(self, meter_setup):
provider, reader = meter_setup
meter = provider.get_meter("test.pipeline")
counter = meter.create_counter("test_counter_a")
hist = meter.create_histogram("test_hist_a")
counter.add(5)
hist.record(2.0)
data = reader.get_metrics_data()
names = {
m.name
for rm in data.resource_metrics
for sm in rm.scope_metrics
for m in sm.metrics
}
assert "test_counter_a" in names
assert "test_hist_a" in names
class TestCreateMeter:
"""Verify create_meter returns MockMeter or real meter."""
def test_returns_mock_when_disabled(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "false")
import perf._meter as mod
mod._provider_ready = False
m = mod.create_meter("test")
from perf._meter import MockMeter
assert isinstance(m, MockMeter)

161
tests/perf/test_tracer.py Normal file
View File

@@ -0,0 +1,161 @@
"""Tests for real OTel TracerProvider setup and span lifecycle."""
from __future__ import annotations
import pytest
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import (
SimpleSpanProcessor,
SpanExporter,
SpanExportResult,
)
class _MemoryExporter(SpanExporter):
"""Lightweight in-memory span collector for tests."""
def __init__(self):
self.spans = []
def export(self, spans):
self.spans.extend(spans)
return SpanExportResult.SUCCESS
def shutdown(self):
pass
def get_finished_spans(self):
return list(self.spans)
@pytest.fixture()
def memory_exporter():
"""Set up an in-memory exporter and restore the global provider after."""
original = trace.get_tracer_provider()
exporter = _MemoryExporter()
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(exporter))
trace.set_tracer_provider(provider)
yield exporter
provider.shutdown()
# Reset to original (or no-op proxy)
trace._TRACER_PROVIDER = original
trace._TRACER_PROVIDER_SET_ONCE._done = False
class TestRealTracerSpans:
"""Verify span creation and attributes with a real provider."""
def test_span_created(self, memory_exporter):
tracer = trace.get_tracer("test.pipeline")
with tracer.start_as_current_span("test.step") as span:
span.set_attribute("rows.out", 42)
span.set_attribute("pipeline", "readmissions")
spans = memory_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].name == "test.step"
assert spans[0].attributes["rows.out"] == 42
assert spans[0].attributes["pipeline"] == "readmissions"
def test_nested_spans(self, memory_exporter):
tracer = trace.get_tracer("test.pipeline")
with tracer.start_as_current_span("pipeline.run") as root:
root.set_attribute("pipeline", "core")
with tracer.start_as_current_span("step.encounter") as step:
step.set_attribute("step", "core.encounter")
with tracer.start_as_current_span("load.eligibility") as load:
load.set_attribute("table", "input_layer.eligibility")
spans = memory_exporter.get_finished_spans()
assert len(spans) == 3
# Verify parent-child relationships
names = [s.name for s in spans]
assert "load.eligibility" in names
assert "step.encounter" in names
assert "pipeline.run" in names
load_span = next(s for s in spans if s.name == "load.eligibility")
step_span = next(s for s in spans if s.name == "step.encounter")
root_span = next(s for s in spans if s.name == "pipeline.run")
assert load_span.parent.span_id == step_span.context.span_id
assert step_span.parent.span_id == root_span.context.span_id
assert root_span.parent is None
def test_span_records_exception(self, memory_exporter):
tracer = trace.get_tracer("test.pipeline")
try:
with tracer.start_as_current_span("failing.step"):
raise ValueError("schema mismatch")
except ValueError:
pass
spans = memory_exporter.get_finished_spans()
assert len(spans) == 1
events = spans[0].events
assert len(events) == 1
assert events[0].name == "exception"
def test_span_status(self, memory_exporter):
from opentelemetry.trace import StatusCode
tracer = trace.get_tracer("test.pipeline")
with tracer.start_as_current_span("ok.step") as span:
span.set_status(StatusCode.OK)
spans = memory_exporter.get_finished_spans()
assert spans[0].status.status_code == StatusCode.OK
def test_span_timing(self, memory_exporter):
tracer = trace.get_tracer("test.pipeline")
with tracer.start_as_current_span("timed.step"):
pass
spans = memory_exporter.get_finished_spans()
s = spans[0]
assert s.end_time > s.start_time
class TestCreateTracer:
"""Verify create_tracer returns MockTracer or real tracer."""
def test_returns_mock_when_disabled(self, monkeypatch):
monkeypatch.setenv("STACK_TELEMETRY", "false")
import perf._tracer as mod
mod._provider_ready = False
t = mod.create_tracer("test")
from perf._tracer import MockTracer
assert isinstance(t, MockTracer)
def test_returns_real_when_provider_ready(self, memory_exporter):
import perf._tracer as mod
mod._provider_ready = True
t = mod.create_tracer("test.scope")
# Should be a real tracer (not MockTracer)
from perf._tracer import MockTracer
assert not isinstance(t, MockTracer)
mod._provider_ready = False
class TestEndpointResolution:
"""Verify OTLP endpoint reads from env and config."""
def test_env_override(self, monkeypatch):
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://custom:4317")
from perf._tracer import _get_endpoint
assert _get_endpoint() == "http://custom:4317"
def test_default_fallback(self, monkeypatch):
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
from perf._tracer import _get_endpoint
endpoint = _get_endpoint()
assert "4317" in endpoint

301
uv.lock generated
View File

@@ -228,6 +228,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asgiref"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/40/f03da1264ae8f7cfdbf9146542e5e7e8100a4c66ab48e791df9a03d3f6c0/asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce", size = 38550, upload-time = "2026-02-03T13:30:14.33Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/0a/a72d10ed65068e115044937873362e6e32fab1b7dce0046aeb224682c989/asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133", size = 24345, upload-time = "2026-02-03T13:30:13.039Z" },
]
[[package]]
name = "attrs"
version = "25.4.0"
@@ -1953,6 +1962,158 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/51/bc/1559d46557fe6eca0b46c88d4c2676285f1f3be2e8d06bb5d15fbffc814a/opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa", size = 20416, upload-time = "2026-03-04T14:17:23.801Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149", size = 18369, upload-time = "2026-03-04T14:17:04.796Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-common" },
{ name = "opentelemetry-proto" },
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/7f/b9e60435cfcc7590fa87436edad6822240dddbc184643a2a005301cc31f4/opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740", size = 25759, upload-time = "2026-03-04T14:17:24.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/6f/7ee0980afcbdcd2d40362da16f7f9796bd083bf7f0b8e038abfbc0300f5d/opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52", size = 20304, upload-time = "2026-03-04T14:17:05.942Z" },
]
[[package]]
name = "opentelemetry-exporter-prometheus"
version = "0.61b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
{ name = "prometheus-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4a/20/9e818fd364d12e8d0cfdce4a3b2d82e24d98c4ceebb315de6b6770b5f214/opentelemetry_exporter_prometheus-0.61b0.tar.gz", hash = "sha256:7c4919bd8e79abd62b610767e80f42c9c3a06c5183f4dd9141eedeb57aea284b", size = 15136, upload-time = "2026-03-04T14:17:26.275Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/4a/b65d40e94d1d930aee73a1a2857211ee6ab10ce3686cbdae5eea78cd9d34/opentelemetry_exporter_prometheus-0.61b0-py3-none-any.whl", hash = "sha256:3013b41f4370143d48d219a2351473761423e5882fa4c213811eaefacba39cb7", size = 13149, upload-time = "2026-03-04T14:17:08.983Z" },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.61b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" },
]
[[package]]
name = "opentelemetry-instrumentation-asgi"
version = "0.61b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/3e/143cf5c034e58037307e6a24f06e0dd64b2c49ae60a965fc580027581931/opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5", size = 26691, upload-time = "2026-03-04T14:20:21.038Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/78/154470cf9d741a7487fbb5067357b87386475bbb77948a6707cae982e158/opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4", size = 16980, upload-time = "2026-03-04T14:19:10.894Z" },
]
[[package]]
name = "opentelemetry-instrumentation-fastapi"
version = "0.61b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-instrumentation-asgi" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "opentelemetry-util-http" },
]
sdist = { url = "https://files.pythonhosted.org/packages/37/35/aa727bb6e6ef930dcdc96a617b83748fece57b43c47d83ba8d83fbeca657/opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f", size = 24800, upload-time = "2026-03-04T14:20:32.759Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/05/acfeb2cccd434242a0a7d0ea29afaf077e04b42b35b485d89aee4e0d9340/opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c", size = 13485, upload-time = "2026-03-04T14:19:30.351Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/77/dd38991db037fdfce45849491cb61de5ab000f49824a00230afb112a4392/opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd", size = 45667, upload-time = "2026-03-04T14:17:31.194Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f", size = 72073, upload-time = "2026-03-04T14:17:16.673Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.61b0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" },
]
[[package]]
name = "opentelemetry-util-http"
version = "0.61b0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/3c/f0196223efc5c4ca19f8fad3d5462b171ac6333013335ce540c01af419e9/opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572", size = 11361, upload-time = "2026-03-04T14:20:57.01Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/e5/c08aaaf2f64288d2b6ef65741d2de5454e64af3e050f34285fb1907492fe/opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33", size = 9281, upload-time = "2026-03-04T14:20:08.364Z" },
]
[[package]]
name = "orderly-set"
version = "5.5.0"
@@ -2183,6 +2344,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/18/72c216f4ab0c82b907009668f79183ae029116ff0dd245d56ef58aac48e7/polars_runtime_32-1.38.1-cp310-abi3-win_arm64.whl", hash = "sha256:6d07d0cc832bfe4fb54b6e04218c2c27afcfa6b9498f9f6bbf262a00d58cc7c4", size = 41639413, upload-time = "2026-02-06T18:12:22.044Z" },
]
[[package]]
name = "prometheus-client"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/58/a794d23feb6b00fc0c72787d7e87d872a6730dd9ed7c7b3e954637d8f280/prometheus_client-0.24.1.tar.gz", hash = "sha256:7e0ced7fbbd40f7b84962d5d2ab6f17ef88a72504dcf7c0b40737b43b2a461f9", size = 85616, upload-time = "2026-01-14T15:26:26.965Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/c3/24a2f845e3917201628ecaba4f18bab4d18a337834c1df2a159ee9d22a42/prometheus_client-0.24.1-py3-none-any.whl", hash = "sha256:150db128af71a5c2482b36e588fc8a6b95e498750da4b17065947c16070f4055", size = 64057, upload-time = "2026-01-14T15:26:24.42Z" },
]
[[package]]
name = "propcache"
version = "0.4.1"
@@ -2938,6 +3108,13 @@ all = [
{ name = "fsspec" },
{ name = "httpx" },
{ name = "narwhals" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-prometheus" },
{ name = "opentelemetry-instrumentation-fastapi" },
{ name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "psutil" },
{ name = "pyarrow" },
{ name = "pydantic" },
{ name = "pyjwt" },
@@ -3014,6 +3191,18 @@ lake = [
{ name = "pydantic" },
{ name = "sqlglot" },
]
perf = [
{ name = "duckdb" },
{ name = "httpx" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-grpc" },
{ name = "opentelemetry-exporter-prometheus" },
{ name = "opentelemetry-instrumentation-fastapi" },
{ name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "psutil" },
{ name = "pydantic" },
]
pfs = [
{ name = "duckdb" },
{ name = "fsspec" },
@@ -3074,6 +3263,13 @@ requires-dist = [
{ name = "narwhals", marker = "extra == 'cms'", specifier = ">=2.17.0" },
{ name = "narwhals", marker = "extra == 'pfs'", specifier = ">=2.17.0" },
{ name = "narwhals", marker = "extra == 'rex'", specifier = ">=2.17.0" },
{ name = "opentelemetry-api", marker = "extra == 'perf'", specifier = ">=1.25.0" },
{ name = "opentelemetry-exporter-otlp-proto-grpc", marker = "extra == 'perf'", specifier = ">=1.25.0" },
{ name = "opentelemetry-exporter-prometheus", marker = "extra == 'perf'", specifier = ">=0.46b0" },
{ name = "opentelemetry-instrumentation-fastapi", marker = "extra == 'perf'", specifier = ">=0.46b0" },
{ name = "opentelemetry-sdk", marker = "extra == 'perf'", specifier = ">=1.25.0" },
{ name = "opentelemetry-semantic-conventions", marker = "extra == 'perf'", specifier = ">=0.46b0" },
{ name = "psutil", marker = "extra == 'perf'", specifier = ">=5.9.0" },
{ name = "pyarrow", marker = "extra == 'aco'", specifier = ">=23.0.0" },
{ name = "pyarrow", marker = "extra == 'bcda'", specifier = ">=23.0.0" },
{ name = "pyarrow", marker = "extra == 'rex'", specifier = ">=23.0.0" },
@@ -3101,9 +3297,11 @@ requires-dist = [
{ name = "stack", extras = ["conf"], marker = "extra == 'api'" },
{ name = "stack", extras = ["conf"], marker = "extra == 'bcda'" },
{ name = "stack", extras = ["conf"], marker = "extra == 'bib'" },
{ name = "stack", extras = ["conf"], marker = "extra == 'perf'" },
{ name = "stack", extras = ["conf"], marker = "extra == 'pfs'" },
{ name = "stack", extras = ["conf"], marker = "extra == 'rex'" },
{ name = "stack", extras = ["lake"], marker = "extra == 'all'" },
{ name = "stack", extras = ["perf"], marker = "extra == 'all'" },
{ name = "stack", extras = ["pfs"], marker = "extra == 'all'" },
{ name = "stack", extras = ["rex"], marker = "extra == 'all'" },
{ name = "stack", extras = ["rex"], marker = "extra == 'pfs'" },
@@ -3111,7 +3309,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.41.0" },
{ name = "uvicorn", marker = "extra == 'cli'", specifier = ">=0.41.0" },
]
provides-extras = ["conf", "aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "pfs", "rex", "lake", "aws", "gcp", "azure", "all"]
provides-extras = ["conf", "aco", "api", "bcda", "bib", "bls", "ccw", "cli", "cms", "pfs", "rex", "perf", "lake", "aws", "gcp", "azure", "all"]
[package.metadata.requires-dev]
dev = [
@@ -3286,66 +3484,51 @@ wheels = [
[[package]]
name = "wrapt"
version = "2.1.2"
version = "1.17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/b6/1db817582c49c7fcbb7df6809d0f515af29d7c2fbf57eb44c36e98fb1492/wrapt-2.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ff2aad9c4cda28a8f0653fc2d487596458c2a3f475e56ba02909e950a9efa6a9", size = 61255, upload-time = "2026-03-06T02:52:45.663Z" },
{ url = "https://files.pythonhosted.org/packages/a2/16/9b02a6b99c09227c93cd4b73acc3678114154ec38da53043c0ddc1fba0dc/wrapt-2.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6433ea84e1cfacf32021d2a4ee909554ade7fd392caa6f7c13f1f4bf7b8e8748", size = 61848, upload-time = "2026-03-06T02:53:48.728Z" },
{ url = "https://files.pythonhosted.org/packages/af/aa/ead46a88f9ec3a432a4832dfedb84092fc35af2d0ba40cd04aea3889f247/wrapt-2.1.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c20b757c268d30d6215916a5fa8461048d023865d888e437fab451139cad6c8e", size = 121433, upload-time = "2026-03-06T02:54:40.328Z" },
{ url = "https://files.pythonhosted.org/packages/3a/9f/742c7c7cdf58b59085a1ee4b6c37b013f66ac33673a7ef4aaed5e992bc33/wrapt-2.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79847b83eb38e70d93dc392c7c5b587efe65b3e7afcc167aa8abd5d60e8761c8", size = 123013, upload-time = "2026-03-06T02:53:26.58Z" },
{ url = "https://files.pythonhosted.org/packages/e8/44/2c3dd45d53236b7ed7c646fcf212251dc19e48e599debd3926b52310fafb/wrapt-2.1.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f8fba1bae256186a83d1875b2b1f4e2d1242e8fac0f58ec0d7e41b26967b965c", size = 117326, upload-time = "2026-03-06T02:53:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/74/e2/b17d66abc26bd96f89dec0ecd0ef03da4a1286e6ff793839ec431b9fae57/wrapt-2.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e3d3b35eedcf5f7d022291ecd7533321c4775f7b9cd0050a31a68499ba45757c", size = 121444, upload-time = "2026-03-06T02:54:09.5Z" },
{ url = "https://files.pythonhosted.org/packages/3c/62/e2977843fdf9f03daf1586a0ff49060b1b2fc7ff85a7ea82b6217c1ae36e/wrapt-2.1.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6f2c5390460de57fa9582bc8a1b7a6c86e1a41dfad74c5225fc07044c15cc8d1", size = 116237, upload-time = "2026-03-06T02:54:03.884Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/27fc67914e68d740bce512f11734aec08696e6b17641fef8867c00c949fc/wrapt-2.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7dfa9f2cf65d027b951d05c662cc99ee3bd01f6e4691ed39848a7a5fffc902b2", size = 120563, upload-time = "2026-03-06T02:53:20.412Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9f/b750b3692ed2ef4705cb305bd68858e73010492b80e43d2a4faa5573cbe7/wrapt-2.1.2-cp312-cp312-win32.whl", hash = "sha256:eba8155747eb2cae4a0b913d9ebd12a1db4d860fc4c829d7578c7b989bd3f2f0", size = 58198, upload-time = "2026-03-06T02:53:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/8e/b2/feecfe29f28483d888d76a48f03c4c4d8afea944dbee2b0cd3380f9df032/wrapt-2.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1c51c738d7d9faa0b3601708e7e2eda9bf779e1b601dce6c77411f2a1b324a63", size = 60441, upload-time = "2026-03-06T02:52:47.138Z" },
{ url = "https://files.pythonhosted.org/packages/44/e1/e328f605d6e208547ea9fd120804fcdec68536ac748987a68c47c606eea8/wrapt-2.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:c8e46ae8e4032792eb2f677dbd0d557170a8e5524d22acc55199f43efedd39bf", size = 58836, upload-time = "2026-03-06T02:53:22.053Z" },
{ url = "https://files.pythonhosted.org/packages/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" },
{ url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" },
{ url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" },
{ url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" },
{ url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" },
{ url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" },
{ url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" },
{ url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" },
{ url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" },
{ url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" },
{ url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" },
{ url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" },
{ url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" },
{ url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" },
{ url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" },
{ url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" },
{ url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" },
{ url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" },
{ url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
{ url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
{ url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
{ url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
{ url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
{ url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
{ url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
{ url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
{ url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
{ url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
{ url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
{ url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
{ url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
{ url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
{ url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
{ url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
{ url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
{ url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
{ url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
{ url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" },
{ url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" },
{ url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" },
{ url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" },
{ url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" },
{ url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" },
{ url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" },
{ url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" },
{ url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" },
{ url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" },
{ url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" },
{ url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" },
{ url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" },
{ url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" },
{ url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" },
{ url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" },
{ url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" },
{ url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" },
{ url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" },
{ url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" },
{ url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" },
{ url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" },
{ url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" },
{ url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" },
{ url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" },
{ url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" },
{ url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" },
{ url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" },
{ url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
]
[[package]]