173 lines
5.2 KiB
Python
173 lines
5.2 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
import pytest
|
|
from opentelemetry.sdk.trace import ReadableSpan
|
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
|
|
|
from app import telemetry
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"canary",
|
|
[
|
|
"Bearer canary-authorization-value",
|
|
"person@example.test",
|
|
"+7 (999) 123-45-67",
|
|
"https://portal.example/path?token=canary",
|
|
"aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb.cccccccccc",
|
|
],
|
|
)
|
|
def test_redaction_removes_canaries_from_json_stdout(canary: str) -> None:
|
|
record = logging.LogRecord(
|
|
"test",
|
|
logging.INFO,
|
|
__file__,
|
|
1,
|
|
"safe event %s",
|
|
(canary,),
|
|
None,
|
|
)
|
|
record.event_name = "test.canary"
|
|
record.telemetry_attributes = {
|
|
"authorization": canary,
|
|
"nested": {"email": canary, "safe": canary},
|
|
}
|
|
|
|
telemetry.RedactionFilter().filter(record)
|
|
output = telemetry.JsonFormatter().format(record)
|
|
|
|
assert canary not in output
|
|
assert json.loads(output)["event"] == "test.canary"
|
|
|
|
|
|
def test_redaction_blocks_sensitive_keys_and_bounds_collections() -> None:
|
|
result = telemetry.redact(
|
|
{
|
|
"db.statement": "SELECT secret FROM users",
|
|
"object_key": "private/file.txt",
|
|
"safe": list(range(100)),
|
|
}
|
|
)
|
|
|
|
assert result["db.statement"] == "[REDACTED]"
|
|
assert result["object_key"] == "[REDACTED]"
|
|
assert len(result["safe"]) == 32
|
|
|
|
|
|
def test_redaction_drops_exception_text_before_otlp() -> None:
|
|
try:
|
|
raise RuntimeError("secret-token-in-exception")
|
|
except RuntimeError:
|
|
record = logging.LogRecord(
|
|
"test",
|
|
logging.ERROR,
|
|
__file__,
|
|
1,
|
|
"operation failed",
|
|
(),
|
|
exc_info=__import__("sys").exc_info(),
|
|
)
|
|
|
|
telemetry.RedactionFilter().filter(record)
|
|
|
|
assert record.exc_info is None
|
|
assert record.telemetry_attributes["error.type"] == "RuntimeError"
|
|
assert "secret-token-in-exception" not in telemetry.JsonFormatter().format(record)
|
|
|
|
|
|
def test_span_processor_replaces_immutable_sdk_attributes() -> None:
|
|
exporter = InMemorySpanExporter()
|
|
processor = telemetry.RedactingBatchSpanProcessor(exporter)
|
|
span = ReadableSpan(
|
|
name="crm.request",
|
|
attributes={"url.full": "https://crm.example/?token=secret", "safe.outcome": "success"},
|
|
)
|
|
|
|
processor.on_end(span)
|
|
processor.shutdown()
|
|
|
|
assert span.attributes["url.full"] == "[REDACTED]"
|
|
assert span.attributes["safe.outcome"] == "success"
|
|
|
|
|
|
def test_init_without_endpoint_is_backward_compatible(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
|
monkeypatch.setattr(telemetry, "_runtime", None)
|
|
|
|
runtime = telemetry.init_telemetry("bitrix-sync-worker")
|
|
|
|
assert runtime.tracer_provider is None
|
|
assert runtime.meter_provider is None
|
|
assert runtime.logger_provider is None
|
|
telemetry.shutdown_telemetry()
|
|
|
|
|
|
def test_each_process_has_distinct_service_resource() -> None:
|
|
names = {
|
|
telemetry._resource(name).attributes["service.name"] # noqa: SLF001
|
|
for name in telemetry.SERVICE_NAMES
|
|
}
|
|
|
|
assert names == {
|
|
"bitrix-sync-api",
|
|
"bitrix-sync-worker",
|
|
"bitrix-sync-reconciliation",
|
|
}
|
|
|
|
|
|
def test_init_is_fail_open_when_exporter_construction_fails(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317")
|
|
monkeypatch.setattr(telemetry, "_runtime", None)
|
|
|
|
def fail_exporter(**_kwargs: Any) -> None:
|
|
raise RuntimeError("collector unavailable")
|
|
|
|
monkeypatch.setattr(telemetry, "OTLPSpanExporter", fail_exporter)
|
|
|
|
runtime = telemetry.init_telemetry("bitrix-sync-reconciliation")
|
|
|
|
assert runtime.tracer_provider is None
|
|
telemetry.shutdown_telemetry()
|
|
|
|
|
|
def test_business_metric_attributes_are_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: list[dict[str, str]] = []
|
|
|
|
class Instrument:
|
|
def add(self, _value: int, attributes: dict[str, str]) -> None:
|
|
captured.append(attributes)
|
|
|
|
def record(self, _value: float, attributes: dict[str, str]) -> None:
|
|
captured.append(attributes)
|
|
|
|
class Meter:
|
|
def create_counter(self, *_args: Any, **_kwargs: Any) -> Instrument:
|
|
return Instrument()
|
|
|
|
def create_histogram(self, *_args: Any, **_kwargs: Any) -> Instrument:
|
|
return Instrument()
|
|
|
|
monkeypatch.setattr(telemetry.metrics, "get_meter", lambda _name: Meter())
|
|
|
|
telemetry.record_workflow("user-2f3c0a5e-identifier", "novel-state", 0.5)
|
|
telemetry.record_crm("unknown.dynamic.method", "novel-state", 0.1)
|
|
|
|
assert captured
|
|
assert all("user-2f3c0a5e-identifier" not in values.values() for values in captured)
|
|
assert all("novel-state" not in values.values() for values in captured)
|
|
|
|
|
|
def test_business_metrics_are_fail_open(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def unavailable(_name: str) -> None:
|
|
raise RuntimeError("metrics unavailable")
|
|
|
|
monkeypatch.setattr(telemetry.metrics, "get_meter", unavailable)
|
|
|
|
telemetry.record_claim("task", 1)
|