clientwright 0.1.0__py3-none-any.whl
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.
- clientwright/__init__.py +179 -0
- clientwright/__version__.py +1 -0
- clientwright/adapters/__init__.py +3 -0
- clientwright/adapters/_httpx_shared.py +831 -0
- clientwright/adapters/_lazy.py +30 -0
- clientwright/adapters/aiohttp/__init__.py +45 -0
- clientwright/adapters/aiohttp/_imports.py +30 -0
- clientwright/adapters/aiohttp/adapter.py +236 -0
- clientwright/adapters/aiohttp/capabilities.py +81 -0
- clientwright/adapters/aiohttp/classify.py +59 -0
- clientwright/adapters/aiohttp/errors.py +57 -0
- clientwright/adapters/aiohttp/middleware.py +103 -0
- clientwright/adapters/aiohttp/normalize.py +64 -0
- clientwright/adapters/aiohttp/options.py +16 -0
- clientwright/adapters/aiohttp/trace.py +109 -0
- clientwright/adapters/aiohttp/views.py +108 -0
- clientwright/adapters/httpx/__init__.py +45 -0
- clientwright/adapters/httpx/_imports.py +17 -0
- clientwright/adapters/httpx/adapter.py +39 -0
- clientwright/adapters/httpx/capabilities.py +9 -0
- clientwright/adapters/httpx/classify.py +14 -0
- clientwright/adapters/httpx/errors.py +35 -0
- clientwright/adapters/httpx/normalize.py +27 -0
- clientwright/adapters/httpx/normalize_sync.py +27 -0
- clientwright/adapters/httpx/transport.py +40 -0
- clientwright/adapters/httpx/views.py +46 -0
- clientwright/adapters/httpx2/__init__.py +46 -0
- clientwright/adapters/httpx2/_imports.py +18 -0
- clientwright/adapters/httpx2/adapter.py +37 -0
- clientwright/adapters/httpx2/capabilities.py +9 -0
- clientwright/adapters/httpx2/classify.py +14 -0
- clientwright/adapters/httpx2/errors.py +36 -0
- clientwright/adapters/httpx2/normalize.py +27 -0
- clientwright/adapters/httpx2/normalize_sync.py +27 -0
- clientwright/adapters/httpx2/transport.py +35 -0
- clientwright/adapters/httpx2/views.py +45 -0
- clientwright/adapters/observability/__init__.py +26 -0
- clientwright/adapters/observability/_metrics/__init__.py +1 -0
- clientwright/adapters/observability/_metrics/prometheus.py +200 -0
- clientwright/adapters/observability/_tracing/__init__.py +3 -0
- clientwright/adapters/observability/_tracing/otel.py +61 -0
- clientwright/adapters/requests/__init__.py +45 -0
- clientwright/adapters/requests/_imports.py +21 -0
- clientwright/adapters/requests/adapter.py +206 -0
- clientwright/adapters/requests/capabilities.py +80 -0
- clientwright/adapters/requests/classify.py +62 -0
- clientwright/adapters/requests/errors.py +40 -0
- clientwright/adapters/requests/normalize.py +63 -0
- clientwright/adapters/requests/views.py +121 -0
- clientwright/adapters/urllib3/__init__.py +48 -0
- clientwright/adapters/urllib3/_imports.py +18 -0
- clientwright/adapters/urllib3/adapter.py +260 -0
- clientwright/adapters/urllib3/capabilities.py +86 -0
- clientwright/adapters/urllib3/classify.py +46 -0
- clientwright/adapters/urllib3/errors.py +55 -0
- clientwright/adapters/urllib3/normalize.py +51 -0
- clientwright/adapters/urllib3/views.py +119 -0
- clientwright/contrib/__init__.py +3 -0
- clientwright/contrib/deadline.py +107 -0
- clientwright/contrib/dishka.py +80 -0
- clientwright/core/__init__.py +6 -0
- clientwright/core/balancer/__init__.py +1 -0
- clientwright/core/balancer/policy.py +23 -0
- clientwright/core/capabilities.py +156 -0
- clientwright/core/config.py +367 -0
- clientwright/core/contracts/__init__.py +33 -0
- clientwright/core/contracts/adapter.py +62 -0
- clientwright/core/contracts/context.py +31 -0
- clientwright/core/contracts/message.py +118 -0
- clientwright/core/contracts/observability.py +92 -0
- clientwright/core/contracts/settings.py +140 -0
- clientwright/core/engine/__init__.py +1 -0
- clientwright/core/engine/aio.py +246 -0
- clientwright/core/engine/base.py +65 -0
- clientwright/core/engine/redirects.py +64 -0
- clientwright/core/engine/suppress.py +30 -0
- clientwright/core/engine/sync.py +241 -0
- clientwright/core/errors.py +113 -0
- clientwright/core/model.py +138 -0
- clientwright/core/native.py +84 -0
- clientwright/core/options.py +46 -0
- clientwright/core/plan.py +190 -0
- clientwright/core/policy/__init__.py +1 -0
- clientwright/core/policy/budget.py +76 -0
- clientwright/core/policy/circuit.py +169 -0
- clientwright/core/policy/concurrency.py +98 -0
- clientwright/core/policy/retry.py +80 -0
- clientwright/core/policy/timeout.py +64 -0
- clientwright/core/registry.py +75 -0
- clientwright/core/telemetry/__init__.py +6 -0
- clientwright/core/telemetry/emitter.py +163 -0
- clientwright/core/telemetry/names.py +61 -0
- clientwright/core/telemetry/null.py +89 -0
- clientwright/core/telemetry/redaction.py +24 -0
- clientwright/core/testing/__init__.py +7 -0
- clientwright/core/testing/doubles.py +107 -0
- clientwright/core/testing/origin.py +201 -0
- clientwright/py.typed +0 -0
- clientwright-0.1.0.dist-info/METADATA +210 -0
- clientwright-0.1.0.dist-info/RECORD +102 -0
- clientwright-0.1.0.dist-info/WHEEL +4 -0
- clientwright-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Adapter registry: string names to lazy import paths.
|
|
2
|
+
|
|
3
|
+
``import clientwright`` pays nothing: adapter modules load only when resolved,
|
|
4
|
+
at which point the adapter's ``_imports`` guard raises a friendly install hint
|
|
5
|
+
if the matching extra is absent. Capability modules are zero-dependency and
|
|
6
|
+
always importable. Dynamic string imports keep the core->adapters import-linter
|
|
7
|
+
contract intact by construction.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from .errors import UnknownAdapterError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .capabilities import AdapterCapabilities
|
|
19
|
+
|
|
20
|
+
_ADAPTERS: dict[str, str] = {
|
|
21
|
+
"httpx": "clientwright.adapters.httpx.adapter:HttpxAdapter",
|
|
22
|
+
"httpx2": "clientwright.adapters.httpx2.adapter:HttpxAdapter",
|
|
23
|
+
"aiohttp": "clientwright.adapters.aiohttp.adapter:AiohttpAdapter",
|
|
24
|
+
"requests": "clientwright.adapters.requests.adapter:RequestsAdapter",
|
|
25
|
+
"urllib3": "clientwright.adapters.urllib3.adapter:Urllib3Adapter",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_CAPABILITIES: dict[str, str] = {
|
|
29
|
+
"httpx": "clientwright.adapters.httpx.capabilities:CAPABILITIES",
|
|
30
|
+
"httpx2": "clientwright.adapters.httpx2.capabilities:CAPABILITIES",
|
|
31
|
+
"aiohttp": "clientwright.adapters.aiohttp.capabilities:CAPABILITIES",
|
|
32
|
+
"requests": "clientwright.adapters.requests.capabilities:CAPABILITIES",
|
|
33
|
+
"urllib3": "clientwright.adapters.urllib3.capabilities:CAPABILITIES",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load(target: str) -> Any:
|
|
38
|
+
module_path, _, attribute = target.partition(":")
|
|
39
|
+
module = importlib.import_module(module_path)
|
|
40
|
+
return getattr(module, attribute)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def register_adapter(name: str, adapter_target: str, capabilities_target: str) -> None:
|
|
44
|
+
"""Register a third-party adapter: ``"module.path:Class"`` strings, lazily loaded."""
|
|
45
|
+
for label, target in (("adapter", adapter_target), ("capabilities", capabilities_target)):
|
|
46
|
+
if ":" not in target:
|
|
47
|
+
raise ValueError(f"{label} target must look like 'module.path:Attribute', got {target!r}")
|
|
48
|
+
_ADAPTERS[name] = adapter_target
|
|
49
|
+
_CAPABILITIES[name] = capabilities_target
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def registered_adapters() -> tuple[str, ...]:
|
|
53
|
+
return tuple(sorted(_ADAPTERS))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_adapter(name: str) -> Any:
|
|
57
|
+
"""Load and return the adapter class registered under ``name``."""
|
|
58
|
+
try:
|
|
59
|
+
target = _ADAPTERS[name]
|
|
60
|
+
except KeyError:
|
|
61
|
+
raise UnknownAdapterError(name, registered_adapters()) from None
|
|
62
|
+
return _load(target)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def capability_records() -> dict[str, AdapterCapabilities]:
|
|
66
|
+
"""Capability record of every registered adapter; importable without extras."""
|
|
67
|
+
return {name: _load(target) for name, target in sorted(_CAPABILITIES.items())}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"capability_records",
|
|
72
|
+
"register_adapter",
|
|
73
|
+
"registered_adapters",
|
|
74
|
+
"resolve_adapter",
|
|
75
|
+
]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""ClientTelemetry - the single emitter driving logs, metrics and spans.
|
|
2
|
+
|
|
3
|
+
Observability closes in ``finally`` for every path: an attempt that dies with
|
|
4
|
+
an exception still decrements in-flight, ends its span and records metrics -
|
|
5
|
+
the legacy kit's event hooks lost every exception outcome.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from ..config import ObservabilityConfig
|
|
14
|
+
from ..contracts.observability import ClientMetricsProtocol, SpanProtocol, TracerProtocol
|
|
15
|
+
from ..model import Attempt, Outcome, RequestInfo
|
|
16
|
+
from .names import OUTCOME_SUCCESS, ROUTE_UNKNOWN, STATUS_NONE
|
|
17
|
+
from .null import NullMetrics, NullTracer
|
|
18
|
+
from .redaction import redact_url
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def outcome_label(outcome: Outcome) -> str:
|
|
22
|
+
return OUTCOME_SUCCESS if outcome.kind is None else outcome.kind.value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def status_label(outcome: Outcome) -> str:
|
|
26
|
+
return STATUS_NONE if outcome.status_code is None else str(outcome.status_code)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(slots=True)
|
|
30
|
+
class CallObservation:
|
|
31
|
+
"""Mutable per-call observation state carried through the engine."""
|
|
32
|
+
|
|
33
|
+
span: SpanProtocol
|
|
34
|
+
started: float
|
|
35
|
+
hops: int = 0
|
|
36
|
+
attempts: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ClientTelemetry:
|
|
40
|
+
"""One instance per built client; adapter/seam are burned in as constants."""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
service: str,
|
|
46
|
+
adapter: str,
|
|
47
|
+
seam: str,
|
|
48
|
+
config: ObservabilityConfig,
|
|
49
|
+
metrics: ClientMetricsProtocol | None,
|
|
50
|
+
tracer: TracerProtocol | None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._service = service
|
|
53
|
+
self._adapter = adapter
|
|
54
|
+
self._seam = seam
|
|
55
|
+
self._config = config
|
|
56
|
+
self._metrics: ClientMetricsProtocol = metrics if (metrics and config.metrics) else NullMetrics()
|
|
57
|
+
self._tracer: TracerProtocol = tracer if (tracer and config.tracing) else NullTracer()
|
|
58
|
+
self._logger = logging.getLogger(f"clientwright.client.{service}")
|
|
59
|
+
self._log_enabled = config.logging
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def tracer(self) -> TracerProtocol:
|
|
63
|
+
return self._tracer
|
|
64
|
+
|
|
65
|
+
def _safe_url(self, info: RequestInfo) -> str:
|
|
66
|
+
return redact_url(info.url, self._config.sensitive_query_params)
|
|
67
|
+
|
|
68
|
+
def call_start(self, info: RequestInfo, started: float) -> CallObservation:
|
|
69
|
+
span = self._tracer.start_span(
|
|
70
|
+
f"HTTP {info.method}",
|
|
71
|
+
attributes={
|
|
72
|
+
"http.request.method": info.method,
|
|
73
|
+
"server.origin": info.origin,
|
|
74
|
+
"url.full": self._safe_url(info),
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
self._metrics.inflight_delta(
|
|
78
|
+
service=self._service, adapter=self._adapter, seam=self._seam, origin=info.origin, delta=1
|
|
79
|
+
)
|
|
80
|
+
if self._log_enabled:
|
|
81
|
+
self._logger.debug(
|
|
82
|
+
"HTTP call started",
|
|
83
|
+
extra={"method": info.method, "url": self._safe_url(info), "origin": info.origin},
|
|
84
|
+
)
|
|
85
|
+
return CallObservation(span=span, started=started)
|
|
86
|
+
|
|
87
|
+
def attempt_end(self, info: RequestInfo, attempt: Attempt) -> None:
|
|
88
|
+
self._metrics.record_attempt(
|
|
89
|
+
service=self._service,
|
|
90
|
+
adapter=self._adapter,
|
|
91
|
+
seam=self._seam,
|
|
92
|
+
method=info.method,
|
|
93
|
+
origin=info.origin,
|
|
94
|
+
outcome=outcome_label(attempt.outcome),
|
|
95
|
+
duration=attempt.duration,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def redirect_hop(self, observation: CallObservation) -> None:
|
|
99
|
+
observation.hops += 1
|
|
100
|
+
self._metrics.record_redirect_hop(service=self._service, adapter=self._adapter, seam=self._seam)
|
|
101
|
+
|
|
102
|
+
def retry_skipped(self, reason: str) -> None:
|
|
103
|
+
self._metrics.record_retry_skipped(service=self._service, adapter=self._adapter, seam=self._seam, reason=reason)
|
|
104
|
+
|
|
105
|
+
def circuit_state_changed(self, key: str, state: str) -> None:
|
|
106
|
+
self._metrics.record_circuit_state(service=self._service, adapter=self._adapter, key=key, state=state)
|
|
107
|
+
|
|
108
|
+
def uninstrumented_call(self) -> None:
|
|
109
|
+
"""A call bypassed the instrumentation seam (e.g. aiohttp middlewares=())."""
|
|
110
|
+
self._metrics.record_uninstrumented_call(service=self._service, adapter=self._adapter, seam=self._seam)
|
|
111
|
+
|
|
112
|
+
def call_end(self, observation: CallObservation, info: RequestInfo, outcome: Outcome, duration: float) -> None:
|
|
113
|
+
route = info.route or ROUTE_UNKNOWN
|
|
114
|
+
try:
|
|
115
|
+
self._metrics.record_call(
|
|
116
|
+
service=self._service,
|
|
117
|
+
adapter=self._adapter,
|
|
118
|
+
seam=self._seam,
|
|
119
|
+
method=info.method,
|
|
120
|
+
origin=info.origin,
|
|
121
|
+
route=route,
|
|
122
|
+
status=status_label(outcome),
|
|
123
|
+
outcome=outcome_label(outcome),
|
|
124
|
+
duration=duration,
|
|
125
|
+
)
|
|
126
|
+
self._metrics.inflight_delta(
|
|
127
|
+
service=self._service, adapter=self._adapter, seam=self._seam, origin=info.origin, delta=-1
|
|
128
|
+
)
|
|
129
|
+
if self._log_enabled:
|
|
130
|
+
extra = {
|
|
131
|
+
"method": info.method,
|
|
132
|
+
"url": self._safe_url(info),
|
|
133
|
+
"origin": info.origin,
|
|
134
|
+
"outcome": outcome_label(outcome),
|
|
135
|
+
"status": status_label(outcome),
|
|
136
|
+
"duration": round(duration, 6),
|
|
137
|
+
"attempts": observation.attempts,
|
|
138
|
+
"hops": observation.hops,
|
|
139
|
+
}
|
|
140
|
+
if outcome.kind is None:
|
|
141
|
+
self._logger.log(self._config.success_log_level, "HTTP call finished", extra=extra)
|
|
142
|
+
else:
|
|
143
|
+
self._logger.warning("HTTP call failed", extra=extra)
|
|
144
|
+
finally:
|
|
145
|
+
if outcome.status_code is not None:
|
|
146
|
+
observation.span.set_attribute("http.response.status_code", outcome.status_code)
|
|
147
|
+
if outcome.kind is not None:
|
|
148
|
+
observation.span.record_failure(outcome_label(outcome))
|
|
149
|
+
observation.span.end()
|
|
150
|
+
|
|
151
|
+
def record_body_duration(self, info: RequestInfo, duration: float) -> None:
|
|
152
|
+
self._metrics.record_body_duration(
|
|
153
|
+
service=self._service,
|
|
154
|
+
adapter=self._adapter,
|
|
155
|
+
seam=self._seam,
|
|
156
|
+
method=info.method,
|
|
157
|
+
origin=info.origin,
|
|
158
|
+
route=info.route or ROUTE_UNKNOWN,
|
|
159
|
+
duration=duration,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = ["CallObservation", "ClientTelemetry", "outcome_label", "status_label"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""FROZEN metric names and label sets - a wire contract with dashboards.
|
|
2
|
+
|
|
3
|
+
Changing anything here is a breaking change of the observability contract and
|
|
4
|
+
requires a dashboard migration plan; see docs/advanced/migration.md for the
|
|
5
|
+
mapping principles from legacy ``rest_client_*``-style names.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Final
|
|
11
|
+
|
|
12
|
+
REQUESTS_TOTAL: Final = "http_client_requests_total"
|
|
13
|
+
REQUEST_DURATION: Final = "http_client_request_duration_seconds"
|
|
14
|
+
BODY_DURATION: Final = "http_client_body_duration_seconds"
|
|
15
|
+
ATTEMPTS_TOTAL: Final = "http_client_attempts_total"
|
|
16
|
+
ATTEMPT_DURATION: Final = "http_client_attempt_duration_seconds"
|
|
17
|
+
INFLIGHT: Final = "http_client_inflight"
|
|
18
|
+
CIRCUIT_STATE: Final = "http_client_circuit_state"
|
|
19
|
+
REDIRECT_HOPS_TOTAL: Final = "http_client_redirect_hops_total"
|
|
20
|
+
RETRY_SKIPPED_TOTAL: Final = "http_client_retry_skipped_total"
|
|
21
|
+
UNINSTRUMENTED_CALLS_TOTAL: Final = "http_client_uninstrumented_calls_total"
|
|
22
|
+
|
|
23
|
+
LABELS_CALL: Final = ("service", "adapter", "seam", "method", "origin", "route", "status", "outcome")
|
|
24
|
+
LABELS_DURATION: Final = ("service", "adapter", "seam", "method", "origin", "route")
|
|
25
|
+
LABELS_ATTEMPT: Final = ("service", "adapter", "seam", "method", "origin", "outcome")
|
|
26
|
+
LABELS_INFLIGHT: Final = ("service", "adapter", "seam", "origin")
|
|
27
|
+
LABELS_CIRCUIT: Final = ("service", "adapter", "key")
|
|
28
|
+
LABELS_SEAM: Final = ("service", "adapter", "seam")
|
|
29
|
+
LABELS_RETRY_SKIPPED: Final = ("service", "adapter", "seam", "reason")
|
|
30
|
+
|
|
31
|
+
OUTCOME_SUCCESS: Final = "success"
|
|
32
|
+
STATUS_NONE: Final = "none"
|
|
33
|
+
ROUTE_UNKNOWN: Final = "unknown"
|
|
34
|
+
|
|
35
|
+
# Inherited verbatim from service-observability's DEFAULT_REST_BUCKETS: tuned
|
|
36
|
+
# for REST latencies and already burned into existing dashboards.
|
|
37
|
+
DEFAULT_BUCKETS: Final = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0)
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"ATTEMPTS_TOTAL",
|
|
41
|
+
"ATTEMPT_DURATION",
|
|
42
|
+
"BODY_DURATION",
|
|
43
|
+
"CIRCUIT_STATE",
|
|
44
|
+
"DEFAULT_BUCKETS",
|
|
45
|
+
"INFLIGHT",
|
|
46
|
+
"LABELS_ATTEMPT",
|
|
47
|
+
"LABELS_CALL",
|
|
48
|
+
"LABELS_CIRCUIT",
|
|
49
|
+
"LABELS_DURATION",
|
|
50
|
+
"LABELS_INFLIGHT",
|
|
51
|
+
"LABELS_RETRY_SKIPPED",
|
|
52
|
+
"LABELS_SEAM",
|
|
53
|
+
"OUTCOME_SUCCESS",
|
|
54
|
+
"REDIRECT_HOPS_TOTAL",
|
|
55
|
+
"REQUESTS_TOTAL",
|
|
56
|
+
"REQUEST_DURATION",
|
|
57
|
+
"RETRY_SKIPPED_TOTAL",
|
|
58
|
+
"ROUTE_UNKNOWN",
|
|
59
|
+
"STATUS_NONE",
|
|
60
|
+
"UNINSTRUMENTED_CALLS_TOTAL",
|
|
61
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Null observability backends: emitters never branch on ``is None``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, MutableMapping
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NullMetrics:
|
|
9
|
+
"""Accepts every record and does nothing."""
|
|
10
|
+
|
|
11
|
+
def record_call(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
service: str,
|
|
15
|
+
adapter: str,
|
|
16
|
+
seam: str,
|
|
17
|
+
method: str,
|
|
18
|
+
origin: str,
|
|
19
|
+
route: str,
|
|
20
|
+
status: str,
|
|
21
|
+
outcome: str,
|
|
22
|
+
duration: float,
|
|
23
|
+
) -> None:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
def record_body_duration(
|
|
27
|
+
self,
|
|
28
|
+
*,
|
|
29
|
+
service: str,
|
|
30
|
+
adapter: str,
|
|
31
|
+
seam: str,
|
|
32
|
+
method: str,
|
|
33
|
+
origin: str,
|
|
34
|
+
route: str,
|
|
35
|
+
duration: float,
|
|
36
|
+
) -> None:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def record_attempt(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
service: str,
|
|
43
|
+
adapter: str,
|
|
44
|
+
seam: str,
|
|
45
|
+
method: str,
|
|
46
|
+
origin: str,
|
|
47
|
+
outcome: str,
|
|
48
|
+
duration: float,
|
|
49
|
+
) -> None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
def inflight_delta(self, *, service: str, adapter: str, seam: str, origin: str, delta: int) -> None:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def record_circuit_state(self, *, service: str, adapter: str, key: str, state: str) -> None:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def record_redirect_hop(self, *, service: str, adapter: str, seam: str) -> None:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def record_retry_skipped(self, *, service: str, adapter: str, seam: str, reason: str) -> None:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class NullSpan:
|
|
69
|
+
def set_attribute(self, key: str, value: str | int | float | bool) -> None:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def record_failure(self, description: str) -> None:
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
def end(self) -> None:
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class NullTracer:
|
|
80
|
+
_SPAN = NullSpan()
|
|
81
|
+
|
|
82
|
+
def start_span(self, name: str, *, attributes: Mapping[str, str | int | float | bool]) -> NullSpan:
|
|
83
|
+
return self._SPAN
|
|
84
|
+
|
|
85
|
+
def inject_context(self, headers: MutableMapping[str, str]) -> None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["NullMetrics", "NullSpan", "NullTracer"]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Secret redaction for logs and spans."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
7
|
+
|
|
8
|
+
REDACTED = "[redacted]"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def redact_headers(headers: Mapping[str, str], sensitive: frozenset[str]) -> dict[str, str]:
|
|
12
|
+
return {name: (REDACTED if name.lower() in sensitive else value) for name, value in headers.items()}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def redact_url(url: str, sensitive_params: frozenset[str]) -> str:
|
|
16
|
+
parts = urlsplit(url)
|
|
17
|
+
if not parts.query:
|
|
18
|
+
return url
|
|
19
|
+
pairs = parse_qsl(parts.query, keep_blank_values=True)
|
|
20
|
+
cleaned = [(name, REDACTED if name.lower() in sensitive_params else value) for name, value in pairs]
|
|
21
|
+
return urlunsplit(parts._replace(query=urlencode(cleaned)))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
__all__ = ["REDACTED", "redact_headers", "redact_url"]
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Test doubles and a fault-injecting origin server, stdlib only."""
|
|
2
|
+
|
|
3
|
+
from .doubles import ManualClock as ManualClock
|
|
4
|
+
from .doubles import RecordingMetrics as RecordingMetrics
|
|
5
|
+
from .origin import OriginServer as OriginServer
|
|
6
|
+
|
|
7
|
+
__all__ = ["ManualClock", "OriginServer", "RecordingMetrics"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Deterministic doubles for engine and telemetry tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ManualClock:
|
|
9
|
+
"""Monotonic clock advanced by hand."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, start: float = 0.0) -> None:
|
|
12
|
+
self._now = start
|
|
13
|
+
|
|
14
|
+
def __call__(self) -> float:
|
|
15
|
+
return self._now
|
|
16
|
+
|
|
17
|
+
def advance(self, seconds: float) -> None:
|
|
18
|
+
self._now += seconds
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class RecordingMetrics:
|
|
23
|
+
"""ClientMetricsProtocol implementation that remembers every record."""
|
|
24
|
+
|
|
25
|
+
calls: list[dict[str, object]] = field(default_factory=list)
|
|
26
|
+
attempts: list[dict[str, object]] = field(default_factory=list)
|
|
27
|
+
body_durations: list[dict[str, object]] = field(default_factory=list)
|
|
28
|
+
inflight: list[dict[str, object]] = field(default_factory=list)
|
|
29
|
+
circuit_states: list[dict[str, object]] = field(default_factory=list)
|
|
30
|
+
redirect_hops: list[dict[str, object]] = field(default_factory=list)
|
|
31
|
+
retry_skips: list[dict[str, object]] = field(default_factory=list)
|
|
32
|
+
uninstrumented: list[dict[str, object]] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
def record_call(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
service: str,
|
|
38
|
+
adapter: str,
|
|
39
|
+
seam: str,
|
|
40
|
+
method: str,
|
|
41
|
+
origin: str,
|
|
42
|
+
route: str,
|
|
43
|
+
status: str,
|
|
44
|
+
outcome: str,
|
|
45
|
+
duration: float,
|
|
46
|
+
) -> None:
|
|
47
|
+
self.calls.append(
|
|
48
|
+
{
|
|
49
|
+
"service": service,
|
|
50
|
+
"adapter": adapter,
|
|
51
|
+
"seam": seam,
|
|
52
|
+
"method": method,
|
|
53
|
+
"origin": origin,
|
|
54
|
+
"route": route,
|
|
55
|
+
"status": status,
|
|
56
|
+
"outcome": outcome,
|
|
57
|
+
"duration": duration,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def record_body_duration(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
service: str,
|
|
65
|
+
adapter: str,
|
|
66
|
+
seam: str,
|
|
67
|
+
method: str,
|
|
68
|
+
origin: str,
|
|
69
|
+
route: str,
|
|
70
|
+
duration: float,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.body_durations.append({"method": method, "origin": origin, "route": route, "duration": duration})
|
|
73
|
+
|
|
74
|
+
def record_attempt(
|
|
75
|
+
self,
|
|
76
|
+
*,
|
|
77
|
+
service: str,
|
|
78
|
+
adapter: str,
|
|
79
|
+
seam: str,
|
|
80
|
+
method: str,
|
|
81
|
+
origin: str,
|
|
82
|
+
outcome: str,
|
|
83
|
+
duration: float,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.attempts.append({"method": method, "origin": origin, "outcome": outcome, "duration": duration})
|
|
86
|
+
|
|
87
|
+
def inflight_delta(self, *, service: str, adapter: str, seam: str, origin: str, delta: int) -> None:
|
|
88
|
+
self.inflight.append({"origin": origin, "delta": delta})
|
|
89
|
+
|
|
90
|
+
def record_circuit_state(self, *, service: str, adapter: str, key: str, state: str) -> None:
|
|
91
|
+
self.circuit_states.append({"key": key, "state": state})
|
|
92
|
+
|
|
93
|
+
def record_redirect_hop(self, *, service: str, adapter: str, seam: str) -> None:
|
|
94
|
+
self.redirect_hops.append({"adapter": adapter})
|
|
95
|
+
|
|
96
|
+
def record_retry_skipped(self, *, service: str, adapter: str, seam: str, reason: str) -> None:
|
|
97
|
+
self.retry_skips.append({"reason": reason})
|
|
98
|
+
|
|
99
|
+
def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None:
|
|
100
|
+
self.uninstrumented.append({"adapter": adapter})
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def inflight_balance(self) -> int:
|
|
104
|
+
return sum(int(record["delta"]) for record in self.inflight) # type: ignore[call-overload]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["ManualClock", "RecordingMetrics"]
|