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.
Files changed (31) hide show
  1. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/PKG-INFO +1 -1
  2. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/pyproject.toml +1 -1
  3. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/__init__.py +3 -0
  4. dispatch_kit-0.3.0/src/dispatch_kit/observe.py +183 -0
  5. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/PKG-INFO +1 -1
  6. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/SOURCES.txt +2 -0
  7. dispatch_kit-0.3.0/tests/test_observe.py +151 -0
  8. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/README.md +0 -0
  9. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/setup.cfg +0 -0
  10. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/approval.py +0 -0
  11. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/budget.py +0 -0
  12. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/dispatch.py +0 -0
  13. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/egress.py +0 -0
  14. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/engine.py +0 -0
  15. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/estimate.py +0 -0
  16. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/faults.py +0 -0
  17. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/provenance.py +0 -0
  18. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/py.typed +0 -0
  19. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit/routing.py +0 -0
  20. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/dependency_links.txt +0 -0
  21. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/requires.txt +0 -0
  22. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/src/dispatch_kit.egg-info/top_level.txt +0 -0
  23. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_approval.py +0 -0
  24. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_budget.py +0 -0
  25. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_dispatch.py +0 -0
  26. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_egress.py +0 -0
  27. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_engine.py +0 -0
  28. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_estimate.py +0 -0
  29. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_faults.py +0 -0
  30. {dispatch_kit-0.2.0 → dispatch_kit-0.3.0}/tests/test_provenance.py +0 -0
  31. {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.2.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.2.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.2.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