Bundled commit across unrelated threads: perf collector/meter/logs/export
updates, SSO bootstrap and traefik/nginx/gitea infra, dev backend scripts
(gitea/github/woodpecker), diag test updates, and misc config.
Also fix test pollution: five tests were doing raw sys.modules.pop("conf")
to simulate ImportError on `import conf`, but raw dict mutation isn't
tracked by monkeypatch and leaked the eviction into subsequent tests.
Downstream tests that did `from conf import secret` at module level held
references to the pre-eviction conf, while re-imports inside tests got a
fresh conf, so patches to conf.cfg._data didn't land on the secret()
closure's cfg — causing tests/conf/test_conf.py::TestSecret and
tests/conf/test_connect.py::TestDuckdb::test_custom_db_name to fail.
Fix: use monkeypatch.delitem(sys.modules, "conf", raising=False) so the
eviction is reverted on teardown. Applied to test_diag_ci.py, test_init.py,
test_tracer.py, and test_resource.py (two sites).
279 lines
9.1 KiB
Python
279 lines
9.1 KiB
Python
"""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
|
|
|
|
def test_returns_mock_when_enabled_but_provider_missing(self, monkeypatch):
|
|
# Provider not yet ready, but telemetry IS enabled — covers the
|
|
# second MockTracer return inside create_tracer().
|
|
monkeypatch.setenv("STACK_TELEMETRY", "true")
|
|
import perf._tracer as mod
|
|
from perf._tracer import MockTracer
|
|
|
|
mod._provider_ready = False
|
|
assert isinstance(mod.create_tracer("test"), MockTracer)
|
|
|
|
|
|
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
|
|
|
|
def test_default_when_conf_unavailable(self, monkeypatch):
|
|
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
|
import sys
|
|
|
|
from perf._tracer import _get_endpoint
|
|
|
|
real_import = (
|
|
__builtins__["__import__"]
|
|
if isinstance(__builtins__, dict)
|
|
else __builtins__.__import__
|
|
)
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if name == "conf":
|
|
raise ImportError("conf unavailable")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr("builtins.__import__", fake_import)
|
|
monkeypatch.delitem(sys.modules, "conf", raising=False)
|
|
assert _get_endpoint() == "http://otel-collector:4317"
|
|
|
|
|
|
class TestSetupTracerProvider:
|
|
"""setup_tracer_provider() configures the global TracerProvider."""
|
|
|
|
def test_sets_provider_ready(self):
|
|
import perf._tracer as mod
|
|
|
|
original_ready = mod._provider_ready
|
|
try:
|
|
mod._provider_ready = False
|
|
mod.setup_tracer_provider()
|
|
assert mod._provider_ready is True
|
|
finally:
|
|
mod._provider_ready = original_ready
|
|
|
|
def test_falls_back_to_file_exporter_when_otlp_fails(self, monkeypatch):
|
|
# Force OTLPSpanExporter import to raise so the fallback path runs.
|
|
import perf._tracer as mod
|
|
|
|
real_import = (
|
|
__builtins__["__import__"]
|
|
if isinstance(__builtins__, dict)
|
|
else __builtins__.__import__
|
|
)
|
|
|
|
def fake_import(name, *args, **kwargs):
|
|
if "otlp" in name:
|
|
raise ImportError("otlp grpc not available")
|
|
return real_import(name, *args, **kwargs)
|
|
|
|
monkeypatch.setattr("builtins.__import__", fake_import)
|
|
original_ready = mod._provider_ready
|
|
try:
|
|
mod._provider_ready = False
|
|
mod.setup_tracer_provider()
|
|
assert mod._provider_ready is True
|
|
finally:
|
|
mod._provider_ready = original_ready
|
|
|
|
|
|
class TestShutdownTracerProvider:
|
|
"""shutdown_tracer_provider() flushes and resets state."""
|
|
|
|
def test_noop_when_not_ready(self):
|
|
import perf._tracer as mod
|
|
|
|
mod._provider_ready = False
|
|
mod.shutdown_tracer_provider()
|
|
assert mod._provider_ready is False
|
|
|
|
def test_resets_provider_ready(self):
|
|
import perf._tracer as mod
|
|
|
|
mod._provider_ready = True
|
|
mod.shutdown_tracer_provider()
|
|
assert mod._provider_ready is False
|
|
|
|
|
|
class TestMockObjects:
|
|
"""MockSpan and MockTracer methods are zero-cost no-ops."""
|
|
|
|
def test_mock_span_methods(self):
|
|
from perf._tracer import MockSpan
|
|
|
|
span = MockSpan()
|
|
span.set_attribute("k", "v")
|
|
span.set_status("OK", description="ok")
|
|
span.record_exception(RuntimeError("boom"))
|
|
span.add_event("ev", {"a": 1})
|
|
|
|
def test_mock_tracer_start_span(self):
|
|
from perf._tracer import MockSpan, MockTracer
|
|
|
|
tracer = MockTracer()
|
|
span = tracer.start_span("test")
|
|
assert isinstance(span, MockSpan)
|
|
with tracer.start_as_current_span("ctx") as s:
|
|
assert isinstance(s, MockSpan)
|
|
|
|
def test_mock_span_as_context_manager(self):
|
|
from perf._tracer import MockSpan
|
|
|
|
span = MockSpan()
|
|
with span as s:
|
|
assert s is span
|