dispatch-kit 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/PKG-INFO +1 -1
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/pyproject.toml +1 -1
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/__init__.py +3 -0
- dispatch_kit-0.3.0/src/dispatch_kit/observe.py +183 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/PKG-INFO +1 -1
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/SOURCES.txt +2 -0
- dispatch_kit-0.3.0/tests/test_observe.py +151 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/README.md +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/setup.cfg +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/approval.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/budget.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/dispatch.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/egress.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/engine.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/estimate.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/faults.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/provenance.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/py.typed +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/routing.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/dependency_links.txt +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/requires.txt +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/top_level.txt +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_approval.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_budget.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_dispatch.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_egress.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_engine.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_estimate.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_faults.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_provenance.py +0 -0
- {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_routing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dispatch-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Pure, fail-closed cost-gating for expensive remote/external work: a hard $ budget cap, backend routing (local->cloud->SDK), and opt-in audited API egress.
|
|
5
5
|
Author-email: Aryan Falahatpisheh <aryanfalahat@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dispatch-kit"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Pure, fail-closed cost-gating for expensive remote/external work: a hard $ budget cap, backend routing (local->cloud->SDK), and opt-in audited API egress."
|
|
9
9
|
authors = [{ name = "Aryan Falahatpisheh", email = "aryanfalahat@gmail.com" }]
|
|
10
10
|
readme = "README.md"
|
|
@@ -46,6 +46,7 @@ from .egress import (
|
|
|
46
46
|
from .engine import drain
|
|
47
47
|
from .estimate import CostEstimate, HostCapabilities, vram_fits
|
|
48
48
|
from .faults import Contained
|
|
49
|
+
from .observe import Observe, ObserveConfig
|
|
49
50
|
from .provenance import Determinism, GpuContext, Provenance
|
|
50
51
|
from .routing import (
|
|
51
52
|
BackendCapabilities,
|
|
@@ -79,6 +80,8 @@ __all__ = [
|
|
|
79
80
|
"Lease",
|
|
80
81
|
"NoEligibleBackendError",
|
|
81
82
|
"NodeIdentity",
|
|
83
|
+
"Observe",
|
|
84
|
+
"ObserveConfig",
|
|
82
85
|
"Provenance",
|
|
83
86
|
"RetriableError",
|
|
84
87
|
"Routable",
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Optional observability facade — OpenTelemetry traces + Sentry errors + structured JSON logs.
|
|
2
|
+
|
|
3
|
+
Opt-in, OFF by default: nothing leaves the box unless the operator sets an env var. Construct one
|
|
4
|
+
:class:`Observe` per service with a service name + an env-var prefix; until a DSN/endpoint is set,
|
|
5
|
+
every call is a zero-cost no-op, so instrumentation can live permanently at the call sites. The
|
|
6
|
+
export SDKs are loaded via :func:`importlib.import_module` (a runtime call, so this module — and the
|
|
7
|
+
whole package — stays import-clean and dependency-free without them); the facade degrades to no-ops
|
|
8
|
+
when they are absent.
|
|
9
|
+
|
|
10
|
+
This owns the parts every service shares: traces, error reporting, JSON log formatting. A service
|
|
11
|
+
that also exports *metrics* builds its own meter, reusing identity via :meth:`Observe.resource`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import importlib
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class ObserveConfig:
|
|
28
|
+
"""Per-service observability settings.
|
|
29
|
+
|
|
30
|
+
``env_prefix`` namespaces the opt-in vars: ``<PREFIX>_SENTRY_DSN`` (falling back to the standard
|
|
31
|
+
``SENTRY_DSN``) enables Sentry, ``<PREFIX>_LOG_JSON`` turns on JSON logs; OTLP traces follow the
|
|
32
|
+
standard ``OTEL_EXPORTER_OTLP_ENDPOINT``. ``pip_hint`` is shown when an export is requested but
|
|
33
|
+
the optional SDK extra is not installed. ``tracer_name`` names the tracer/logger.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
service_name: str
|
|
37
|
+
env_prefix: str
|
|
38
|
+
pip_hint: str
|
|
39
|
+
tracer_name: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _JsonFormatter(logging.Formatter):
|
|
43
|
+
"""One-line JSON per record (ts/level/logger/msg + service/command) so logs are parseable
|
|
44
|
+
and shippable — searchable across containers, not just grep on one box."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, service: str, command: str | None) -> None:
|
|
47
|
+
super().__init__()
|
|
48
|
+
self._base: dict[str, str] = {"service": service}
|
|
49
|
+
if command:
|
|
50
|
+
self._base["command"] = command
|
|
51
|
+
|
|
52
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
53
|
+
payload: dict[str, object] = {
|
|
54
|
+
"ts": self.formatTime(record),
|
|
55
|
+
"level": record.levelname,
|
|
56
|
+
"logger": record.name,
|
|
57
|
+
"msg": record.getMessage(),
|
|
58
|
+
**self._base,
|
|
59
|
+
}
|
|
60
|
+
if record.exc_info:
|
|
61
|
+
payload["exc"] = self.formatException(record.exc_info)
|
|
62
|
+
return json.dumps(payload)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class Observe:
|
|
66
|
+
"""A per-service observability facade: traces (OTel), errors (Sentry), JSON logs.
|
|
67
|
+
|
|
68
|
+
Construct with an :class:`ObserveConfig`, call :meth:`setup` once at process start, then use
|
|
69
|
+
:meth:`span` / :meth:`capture_exception` freely — both no-op when export is off. The
|
|
70
|
+
``build_sentry`` / ``build_tracer`` methods are the seams tests patch.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, config: ObserveConfig) -> None:
|
|
74
|
+
self._cfg = config
|
|
75
|
+
self._log = logging.getLogger(config.tracer_name)
|
|
76
|
+
self._sentry: Any = None
|
|
77
|
+
self._tracer: Any = None
|
|
78
|
+
|
|
79
|
+
def setup(self, *, command: str | None = None) -> None:
|
|
80
|
+
"""Wire whichever exporters the environment opts into. Idempotent — resets first, so it can
|
|
81
|
+
be called once per process or re-run in tests."""
|
|
82
|
+
self.reset()
|
|
83
|
+
self._init_logging(command)
|
|
84
|
+
self._init_sentry()
|
|
85
|
+
self._init_tracer(command)
|
|
86
|
+
|
|
87
|
+
def reset(self) -> None:
|
|
88
|
+
"""Deactivate every exporter (before a re-:meth:`setup` and in tests)."""
|
|
89
|
+
self._sentry = None
|
|
90
|
+
self._tracer = None
|
|
91
|
+
|
|
92
|
+
def is_active(self) -> dict[str, bool]:
|
|
93
|
+
"""Which exporters are live — for a health check and asserted by tests."""
|
|
94
|
+
return {"sentry": self._sentry is not None, "otel": self._tracer is not None}
|
|
95
|
+
|
|
96
|
+
@contextmanager
|
|
97
|
+
def span(self, name: str, **attributes: object) -> Iterator[Any]:
|
|
98
|
+
"""Trace the wrapped block as one OTel span (with ``attributes``). A no-op context yielding
|
|
99
|
+
``None`` when tracing is off, so call sites need no guard."""
|
|
100
|
+
tracer = self._tracer
|
|
101
|
+
if tracer is None:
|
|
102
|
+
yield None
|
|
103
|
+
return
|
|
104
|
+
with tracer.start_as_current_span(name) as active:
|
|
105
|
+
for key, value in attributes.items():
|
|
106
|
+
active.set_attribute(key, value)
|
|
107
|
+
yield active
|
|
108
|
+
|
|
109
|
+
def capture_exception(self, exc: BaseException, **tags: object) -> None:
|
|
110
|
+
"""Report a caught exception to Sentry with ``tags`` for grouping. No-op when off — the
|
|
111
|
+
caller still records its own failure normally."""
|
|
112
|
+
sentry = self._sentry
|
|
113
|
+
if sentry is None:
|
|
114
|
+
return
|
|
115
|
+
with sentry.new_scope() as scope:
|
|
116
|
+
for key, value in tags.items():
|
|
117
|
+
scope.set_tag(key, str(value))
|
|
118
|
+
sentry.capture_exception(exc)
|
|
119
|
+
|
|
120
|
+
def otlp_endpoint(self) -> str | None:
|
|
121
|
+
"""The OTLP endpoint if set — a service building its own meter checks this."""
|
|
122
|
+
return os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT") or None
|
|
123
|
+
|
|
124
|
+
def resource(self, **extra: Any) -> Any:
|
|
125
|
+
"""An OTel ``Resource`` carrying ``service.name`` + ``extra`` — so a service's own meter
|
|
126
|
+
shares this service's identity. Requires the opentelemetry SDK."""
|
|
127
|
+
resources = importlib.import_module("opentelemetry.sdk.resources")
|
|
128
|
+
return resources.Resource.create({"service.name": self._cfg.service_name, **extra})
|
|
129
|
+
|
|
130
|
+
def _init_logging(self, command: str | None) -> None:
|
|
131
|
+
if not os.environ.get(f"{self._cfg.env_prefix}_LOG_JSON"):
|
|
132
|
+
return
|
|
133
|
+
fmt = _JsonFormatter(self._cfg.service_name, command)
|
|
134
|
+
root = logging.getLogger()
|
|
135
|
+
if not root.handlers:
|
|
136
|
+
root.addHandler(logging.StreamHandler())
|
|
137
|
+
for handler in root.handlers:
|
|
138
|
+
handler.setFormatter(fmt)
|
|
139
|
+
|
|
140
|
+
def _init_sentry(self) -> None:
|
|
141
|
+
dsn = os.environ.get(f"{self._cfg.env_prefix}_SENTRY_DSN") or os.environ.get("SENTRY_DSN")
|
|
142
|
+
if not dsn:
|
|
143
|
+
return
|
|
144
|
+
try:
|
|
145
|
+
self._sentry = self.build_sentry(dsn)
|
|
146
|
+
except ImportError:
|
|
147
|
+
self._log.warning("SENTRY_DSN set but sentry-sdk is missing — %s", self._cfg.pip_hint)
|
|
148
|
+
return
|
|
149
|
+
self._log.info("Sentry error reporting enabled")
|
|
150
|
+
|
|
151
|
+
def _init_tracer(self, command: str | None) -> None:
|
|
152
|
+
endpoint = self.otlp_endpoint()
|
|
153
|
+
if not endpoint:
|
|
154
|
+
return
|
|
155
|
+
try:
|
|
156
|
+
self._tracer = self.build_tracer(command)
|
|
157
|
+
except ImportError:
|
|
158
|
+
self._log.warning(
|
|
159
|
+
"OTEL_EXPORTER_OTLP_ENDPOINT set but opentelemetry is missing — %s",
|
|
160
|
+
self._cfg.pip_hint,
|
|
161
|
+
)
|
|
162
|
+
return
|
|
163
|
+
self._log.info("OpenTelemetry export enabled -> %s", endpoint)
|
|
164
|
+
|
|
165
|
+
def build_sentry(self, dsn: str) -> Any:
|
|
166
|
+
"""Initialise the real Sentry SDK (errors only; OTel owns traces). The dynamic import is the
|
|
167
|
+
seam tests patch + the ImportError surface when the extra is absent."""
|
|
168
|
+
sentry_sdk = importlib.import_module("sentry_sdk")
|
|
169
|
+
sentry_sdk.init(dsn=dsn, send_default_pii=False, traces_sample_rate=0.0)
|
|
170
|
+
return sentry_sdk
|
|
171
|
+
|
|
172
|
+
def build_tracer(self, command: str | None) -> Any:
|
|
173
|
+
"""Register an OTLP-exporting tracer provider over this service's resource; return its
|
|
174
|
+
tracer. Endpoint/headers are read from the standard ``OTEL_EXPORTER_OTLP_*`` env."""
|
|
175
|
+
trace = importlib.import_module("opentelemetry.trace")
|
|
176
|
+
otlp = importlib.import_module("opentelemetry.exporter.otlp.proto.http.trace_exporter")
|
|
177
|
+
sdk_trace = importlib.import_module("opentelemetry.sdk.trace")
|
|
178
|
+
sdk_export = importlib.import_module("opentelemetry.sdk.trace.export")
|
|
179
|
+
extra = {f"{self._cfg.tracer_name}.command": command} if command else {}
|
|
180
|
+
provider = sdk_trace.TracerProvider(resource=self.resource(**extra))
|
|
181
|
+
provider.add_span_processor(sdk_export.BatchSpanProcessor(otlp.OTLPSpanExporter()))
|
|
182
|
+
trace.set_tracer_provider(provider)
|
|
183
|
+
return trace.get_tracer(self._cfg.tracer_name)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dispatch-kit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Pure, fail-closed cost-gating for expensive remote/external work: a hard $ budget cap, backend routing (local->cloud->SDK), and opt-in audited API egress.
|
|
5
5
|
Author-email: Aryan Falahatpisheh <aryanfalahat@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -9,6 +9,7 @@ src/dispatch_kit/egress.py
|
|
|
9
9
|
src/dispatch_kit/engine.py
|
|
10
10
|
src/dispatch_kit/estimate.py
|
|
11
11
|
src/dispatch_kit/faults.py
|
|
12
|
+
src/dispatch_kit/observe.py
|
|
12
13
|
src/dispatch_kit/provenance.py
|
|
13
14
|
src/dispatch_kit/py.typed
|
|
14
15
|
src/dispatch_kit/routing.py
|
|
@@ -24,5 +25,6 @@ tests/test_egress.py
|
|
|
24
25
|
tests/test_engine.py
|
|
25
26
|
tests/test_estimate.py
|
|
26
27
|
tests/test_faults.py
|
|
28
|
+
tests/test_observe.py
|
|
27
29
|
tests/test_provenance.py
|
|
28
30
|
tests/test_routing.py
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The observability facade is OFF by default and a safe no-op until an env var opts in.
|
|
2
|
+
|
|
3
|
+
The real SDKs are patched via the ``build_sentry`` / ``build_tracer`` seams, so these tests need
|
|
4
|
+
neither the export extras nor a network endpoint.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from dispatch_kit.observe import Observe, ObserveConfig, _JsonFormatter
|
|
15
|
+
|
|
16
|
+
_CFG = ObserveConfig(
|
|
17
|
+
service_name="svc",
|
|
18
|
+
env_prefix="SVC",
|
|
19
|
+
pip_hint="pip install 'svc[observe]'",
|
|
20
|
+
tracer_name="svc",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def obs(monkeypatch: pytest.MonkeyPatch) -> Observe:
|
|
26
|
+
"""A fresh facade with every opt-in env var cleared."""
|
|
27
|
+
for var in ("SVC_SENTRY_DSN", "SENTRY_DSN", "OTEL_EXPORTER_OTLP_ENDPOINT", "SVC_LOG_JSON"):
|
|
28
|
+
monkeypatch.delenv(var, raising=False)
|
|
29
|
+
return Observe(_CFG)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _FakeScope:
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self.tags: dict[str, str] = {}
|
|
35
|
+
|
|
36
|
+
def __enter__(self) -> _FakeScope:
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def __exit__(self, *_exc: object) -> bool:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
def set_tag(self, key: str, value: str) -> None:
|
|
43
|
+
self.tags[key] = value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _FakeSentry:
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self.captured: list[tuple[BaseException, dict[str, str]]] = []
|
|
49
|
+
self._scope = _FakeScope()
|
|
50
|
+
|
|
51
|
+
def new_scope(self) -> _FakeScope:
|
|
52
|
+
self._scope = _FakeScope()
|
|
53
|
+
return self._scope
|
|
54
|
+
|
|
55
|
+
def capture_exception(self, exc: BaseException) -> None:
|
|
56
|
+
self.captured.append((exc, dict(self._scope.tags)))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _FakeSpan:
|
|
60
|
+
def __init__(self, name: str) -> None:
|
|
61
|
+
self.name = name
|
|
62
|
+
self.attrs: dict[str, object] = {}
|
|
63
|
+
|
|
64
|
+
def __enter__(self) -> _FakeSpan:
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
def __exit__(self, *_exc: object) -> bool:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
def set_attribute(self, key: str, value: object) -> None:
|
|
71
|
+
self.attrs[key] = value
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _FakeTracer:
|
|
75
|
+
def __init__(self) -> None:
|
|
76
|
+
self.spans: list[_FakeSpan] = []
|
|
77
|
+
|
|
78
|
+
def start_as_current_span(self, name: str) -> _FakeSpan:
|
|
79
|
+
span = _FakeSpan(name)
|
|
80
|
+
self.spans.append(span)
|
|
81
|
+
return span
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_inactive_by_default_is_a_safe_noop(obs: Observe) -> None:
|
|
85
|
+
obs.setup()
|
|
86
|
+
assert obs.is_active() == {"sentry": False, "otel": False}
|
|
87
|
+
with obs.span("op", tag="t") as active:
|
|
88
|
+
assert active is None
|
|
89
|
+
obs.capture_exception(ValueError("x"), op="t") # must not raise
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_sentry_opt_in_forwards_exception_with_tags(
|
|
93
|
+
obs: Observe, monkeypatch: pytest.MonkeyPatch
|
|
94
|
+
) -> None:
|
|
95
|
+
fake = _FakeSentry()
|
|
96
|
+
monkeypatch.setenv("SVC_SENTRY_DSN", "https://k@example.test/1")
|
|
97
|
+
monkeypatch.setattr(obs, "build_sentry", lambda _dsn: fake)
|
|
98
|
+
obs.setup()
|
|
99
|
+
assert obs.is_active()["sentry"] is True
|
|
100
|
+
err = ValueError("boom")
|
|
101
|
+
obs.capture_exception(err, op="x", id="r1")
|
|
102
|
+
assert fake.captured == [(err, {"op": "x", "id": "r1"})]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_otel_opt_in_records_spans(obs: Observe, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
106
|
+
tracer = _FakeTracer()
|
|
107
|
+
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318")
|
|
108
|
+
monkeypatch.setattr(obs, "build_tracer", lambda _cmd: tracer)
|
|
109
|
+
obs.setup(command="work")
|
|
110
|
+
assert obs.is_active()["otel"] is True
|
|
111
|
+
with obs.span("job", op="x") as active:
|
|
112
|
+
assert active is not None
|
|
113
|
+
assert tracer.spans[0].name == "job"
|
|
114
|
+
assert tracer.spans[0].attrs == {"op": "x"}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_missing_extra_logs_and_degrades(
|
|
118
|
+
obs: Observe, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
|
119
|
+
) -> None:
|
|
120
|
+
def _no_sdk(_dsn: str) -> object:
|
|
121
|
+
raise ImportError("no sentry_sdk")
|
|
122
|
+
|
|
123
|
+
monkeypatch.setenv("SVC_SENTRY_DSN", "https://k@example.test/1")
|
|
124
|
+
monkeypatch.setattr(obs, "build_sentry", _no_sdk)
|
|
125
|
+
with caplog.at_level(logging.WARNING):
|
|
126
|
+
obs.setup()
|
|
127
|
+
assert obs.is_active()["sentry"] is False
|
|
128
|
+
assert any("sentry-sdk" in rec.message for rec in caplog.records)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_setup_is_idempotent_and_resets(obs: Observe, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
132
|
+
fake = _FakeSentry()
|
|
133
|
+
monkeypatch.setenv("SVC_SENTRY_DSN", "https://k@example.test/1")
|
|
134
|
+
monkeypatch.setattr(obs, "build_sentry", lambda _dsn: fake)
|
|
135
|
+
obs.setup()
|
|
136
|
+
assert obs.is_active()["sentry"] is True
|
|
137
|
+
monkeypatch.delenv("SVC_SENTRY_DSN")
|
|
138
|
+
obs.setup()
|
|
139
|
+
assert obs.is_active()["sentry"] is False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_json_formatter_emits_structured_record() -> None:
|
|
143
|
+
formatter = _JsonFormatter("svc", "work")
|
|
144
|
+
record = logging.LogRecord("svc.x", logging.INFO, "f.py", 1, "hello %s", ("world",), None)
|
|
145
|
+
payload = json.loads(formatter.format(record))
|
|
146
|
+
assert payload["service"] == "svc"
|
|
147
|
+
assert payload["command"] == "work"
|
|
148
|
+
assert payload["level"] == "INFO"
|
|
149
|
+
assert payload["logger"] == "svc.x"
|
|
150
|
+
assert payload["msg"] == "hello world"
|
|
151
|
+
assert "ts" in payload
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|