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
100 lines
3.1 KiB
Python
100 lines
3.1 KiB
Python
"""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
|