clientwright 0.1.0__tar.gz → 0.2.1__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 (200) hide show
  1. clientwright-0.2.1/CHANGELOG.md +35 -0
  2. {clientwright-0.1.0 → clientwright-0.2.1}/PKG-INFO +10 -1
  3. {clientwright-0.1.0 → clientwright-0.2.1}/README.md +9 -0
  4. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/__init__.py +4 -0
  5. clientwright-0.2.1/clientwright/__version__.py +1 -0
  6. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/_httpx_shared.py +5 -4
  7. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/adapter.py +4 -3
  8. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/adapter.py +4 -3
  9. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/adapter.py +4 -3
  10. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/config.py +21 -3
  11. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/__init__.py +2 -0
  12. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/observability.py +19 -1
  13. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/aio.py +2 -1
  14. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/sync.py +2 -1
  15. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/errors.py +9 -1
  16. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/native.py +6 -1
  17. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/retry.py +16 -2
  18. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/emitter.py +36 -4
  19. {clientwright-0.1.0 → clientwright-0.2.1}/tests/helpers/engine.py +11 -7
  20. clientwright-0.2.1/tests/helpers/telemetry.py +41 -0
  21. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_aiohttp.py +19 -0
  22. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/parity/test_semantics.py +33 -0
  23. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_build.py +15 -0
  24. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/httpx/test_units.py +1 -0
  25. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_adapter.py +1 -0
  26. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_adapter.py +11 -0
  27. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_aio.py +27 -1
  28. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_sync.py +13 -0
  29. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_retry.py +31 -0
  30. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_emitter.py +40 -2
  31. clientwright-0.2.1/tests/unit/core/telemetry/test_masking.py +140 -0
  32. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_redaction.py +7 -0
  33. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_capabilities.py +9 -1
  34. clientwright-0.1.0/CHANGELOG.md +0 -17
  35. clientwright-0.1.0/clientwright/__version__.py +0 -1
  36. {clientwright-0.1.0 → clientwright-0.2.1}/.gitignore +0 -0
  37. {clientwright-0.1.0 → clientwright-0.2.1}/LICENSE +0 -0
  38. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/__init__.py +0 -0
  39. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/_lazy.py +0 -0
  40. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/__init__.py +0 -0
  41. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/_imports.py +0 -0
  42. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/capabilities.py +0 -0
  43. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/classify.py +0 -0
  44. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/errors.py +0 -0
  45. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/middleware.py +0 -0
  46. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/normalize.py +0 -0
  47. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/options.py +0 -0
  48. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/trace.py +0 -0
  49. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/aiohttp/views.py +0 -0
  50. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/__init__.py +0 -0
  51. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/_imports.py +0 -0
  52. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/adapter.py +0 -0
  53. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/capabilities.py +0 -0
  54. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/classify.py +0 -0
  55. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/errors.py +0 -0
  56. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/normalize.py +0 -0
  57. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/normalize_sync.py +0 -0
  58. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/transport.py +0 -0
  59. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx/views.py +0 -0
  60. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/__init__.py +0 -0
  61. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/_imports.py +0 -0
  62. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/adapter.py +0 -0
  63. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/capabilities.py +0 -0
  64. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/classify.py +0 -0
  65. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/errors.py +0 -0
  66. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/normalize.py +0 -0
  67. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/normalize_sync.py +0 -0
  68. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/transport.py +0 -0
  69. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/httpx2/views.py +0 -0
  70. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/__init__.py +0 -0
  71. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/_metrics/__init__.py +0 -0
  72. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/_metrics/prometheus.py +0 -0
  73. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/_tracing/__init__.py +0 -0
  74. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/observability/_tracing/otel.py +0 -0
  75. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/__init__.py +0 -0
  76. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/_imports.py +0 -0
  77. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/capabilities.py +0 -0
  78. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/classify.py +0 -0
  79. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/errors.py +0 -0
  80. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/normalize.py +0 -0
  81. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/requests/views.py +0 -0
  82. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/__init__.py +0 -0
  83. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/_imports.py +0 -0
  84. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/capabilities.py +0 -0
  85. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/classify.py +0 -0
  86. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/errors.py +0 -0
  87. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/normalize.py +0 -0
  88. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/adapters/urllib3/views.py +0 -0
  89. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/contrib/__init__.py +0 -0
  90. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/contrib/deadline.py +0 -0
  91. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/contrib/dishka.py +0 -0
  92. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/__init__.py +0 -0
  93. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/balancer/__init__.py +0 -0
  94. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/balancer/policy.py +0 -0
  95. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/capabilities.py +0 -0
  96. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/adapter.py +0 -0
  97. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/context.py +0 -0
  98. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/message.py +0 -0
  99. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/contracts/settings.py +0 -0
  100. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/__init__.py +0 -0
  101. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/base.py +0 -0
  102. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/redirects.py +0 -0
  103. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/engine/suppress.py +0 -0
  104. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/model.py +0 -0
  105. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/options.py +0 -0
  106. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/plan.py +0 -0
  107. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/__init__.py +0 -0
  108. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/budget.py +0 -0
  109. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/circuit.py +0 -0
  110. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/concurrency.py +0 -0
  111. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/policy/timeout.py +0 -0
  112. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/registry.py +0 -0
  113. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/__init__.py +0 -0
  114. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/names.py +0 -0
  115. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/null.py +0 -0
  116. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/telemetry/redaction.py +0 -0
  117. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/testing/__init__.py +0 -0
  118. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/testing/doubles.py +0 -0
  119. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/core/testing/origin.py +0 -0
  120. {clientwright-0.1.0 → clientwright-0.2.1}/clientwright/py.typed +0 -0
  121. {clientwright-0.1.0 → clientwright-0.2.1}/pyproject.toml +0 -0
  122. {clientwright-0.1.0 → clientwright-0.2.1}/tests/__init__.py +0 -0
  123. {clientwright-0.1.0 → clientwright-0.2.1}/tests/conftest.py +0 -0
  124. {clientwright-0.1.0 → clientwright-0.2.1}/tests/helpers/__init__.py +0 -0
  125. {clientwright-0.1.0 → clientwright-0.2.1}/tests/helpers/drivers.py +0 -0
  126. {clientwright-0.1.0 → clientwright-0.2.1}/tests/helpers/views.py +0 -0
  127. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/__init__.py +0 -0
  128. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/__init__.py +0 -0
  129. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx2.py +0 -0
  130. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx_async.py +0 -0
  131. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_httpx_sync.py +0 -0
  132. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_requests.py +0 -0
  133. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/adapters/test_urllib3.py +0 -0
  134. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/chaos/__init__.py +0 -0
  135. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/chaos/test_faults.py +0 -0
  136. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/chaos/test_origin_faults.py +0 -0
  137. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/conftest.py +0 -0
  138. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/contrib/__init__.py +0 -0
  139. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/contrib/test_deadline.py +0 -0
  140. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/contrib/test_dishka.py +0 -0
  141. {clientwright-0.1.0 → clientwright-0.2.1}/tests/integration/parity/__init__.py +0 -0
  142. {clientwright-0.1.0 → clientwright-0.2.1}/tests/live/__init__.py +0 -0
  143. {clientwright-0.1.0 → clientwright-0.2.1}/tests/live/conftest.py +0 -0
  144. {clientwright-0.1.0 → clientwright-0.2.1}/tests/live/test_public_endpoints.py +0 -0
  145. {clientwright-0.1.0 → clientwright-0.2.1}/tests/live/test_tls_and_dns.py +0 -0
  146. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/__init__.py +0 -0
  147. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/__init__.py +0 -0
  148. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/__init__.py +0 -0
  149. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_normalize.py +0 -0
  150. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_trace.py +0 -0
  151. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/aiohttp/test_units.py +0 -0
  152. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/conftest.py +0 -0
  153. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/httpx/__init__.py +0 -0
  154. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/httpx2/__init__.py +0 -0
  155. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/httpx2/test_units.py +0 -0
  156. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/observability/__init__.py +0 -0
  157. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_exports.py +0 -0
  158. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_otel.py +0 -0
  159. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/observability/test_prometheus.py +0 -0
  160. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/requests/__init__.py +0 -0
  161. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_normalize.py +0 -0
  162. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/requests/test_units.py +0 -0
  163. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/test_bare_install.py +0 -0
  164. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/test_httpx_family_parity.py +0 -0
  165. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/test_httpx_shared.py +0 -0
  166. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/__init__.py +0 -0
  167. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_normalize.py +0 -0
  168. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/adapters/urllib3/test_units.py +0 -0
  169. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/contrib/__init__.py +0 -0
  170. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/contrib/test_deadline.py +0 -0
  171. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/contrib/test_deadline_context.py +0 -0
  172. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/contrib/test_dishka.py +0 -0
  173. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/__init__.py +0 -0
  174. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/__init__.py +0 -0
  175. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_base.py +0 -0
  176. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_redirects.py +0 -0
  177. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/engine/test_suppress.py +0 -0
  178. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/__init__.py +0 -0
  179. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_budget.py +0 -0
  180. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_circuit.py +0 -0
  181. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_circuit_properties.py +0 -0
  182. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_concurrency.py +0 -0
  183. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_retry_properties.py +0 -0
  184. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_timeout.py +0 -0
  185. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/policy/test_timeout_properties.py +0 -0
  186. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/telemetry/__init__.py +0 -0
  187. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/telemetry/test_redaction_properties.py +0 -0
  188. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_balancer.py +0 -0
  189. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_capabilities_properties.py +0 -0
  190. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_config.py +0 -0
  191. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_enum_coercion.py +0 -0
  192. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_errors.py +0 -0
  193. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_model.py +0 -0
  194. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_options.py +0 -0
  195. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_plan.py +0 -0
  196. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_registry.py +0 -0
  197. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/core/test_settings.py +0 -0
  198. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/test_docs_snippets.py +0 -0
  199. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/test_sdk_imports_are_guarded.py +0 -0
  200. {clientwright-0.1.0 → clientwright-0.2.1}/tests/unit/test_version.py +0 -0
@@ -0,0 +1,35 @@
1
+ # Changelog
2
+
3
+ ## [0.2.1](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.2.0...clientwright-v0.2.1) (2026-09-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * retry gating, native override reporting and connection metrics ([#21](https://github.com/bedrock-python/clientwright/issues/21)) ([b82e2dd](https://github.com/bedrock-python/clientwright/commit/b82e2dd886fd049f1f5d4ae8c169bdabe599d585))
9
+
10
+ ## [0.2.0](https://github.com/bedrock-python/clientwright/compare/clientwright-v0.1.0...clientwright-v0.2.0) (2026-08-14)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * add url_masker seam for value-level PII masking, drop dead sensitive_headers knob
16
+
17
+ ### Features
18
+
19
+ * add url_masker seam for value-level PII masking, drop dead sensitive_headers knob ([90f74fb](https://github.com/bedrock-python/clientwright/commit/90f74fbecaafb77b2405c4d06804e3d7dad91b34))
20
+
21
+ ## 0.1.0 (2026-08-14)
22
+
23
+
24
+ ### Features
25
+
26
+ * initial release ([14d4ee6](https://github.com/bedrock-python/clientwright/commit/14d4ee6eba85e6bfb5445b75393891f45cd9878d))
27
+
28
+ ## Changelog
29
+
30
+ All notable changes to this project are documented in this file.
31
+
32
+ This file is generated by [Release Please](https://github.com/googleapis/release-please)
33
+ from [Conventional Commits](https://www.conventionalcommits.org/) — do not edit it by hand.
34
+ The project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html); while the
35
+ version is `0.x`, minor releases may contain breaking changes.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: clientwright
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: HTTP client platform: one resilience and observability core with adapters for httpx, aiohttp, requests, urllib3 and friends
5
5
  Project-URL: Repository, https://github.com/bedrock-python/clientwright
6
6
  Project-URL: Documentation, https://bedrock-python.github.io/clientwright/
@@ -95,6 +95,15 @@ handle = inspect(client) # what actually got applied
95
95
  print(handle.report.dropped) # config the adapter could not express
96
96
  ```
97
97
 
98
+ > [!TIP]
99
+ > **Building this with an AI assistant?** Hand it
100
+ > **[one page](https://bedrock-python.github.io/clientwright/agents/)** instead of the
101
+ > whole site: the whole public API with its real defaults, where the boundary between the
102
+ > core engine and an adapter runs, the rules that break code when they are broken, the
103
+ > mistakes models actually make with this API, and a map of which page to fetch for the
104
+ > rest. Every docs page is also served as raw Markdown at its own URL, and a **Copy page**
105
+ > button at the top of each one hands it straight to a chat window.
106
+
98
107
  ## Why
99
108
 
100
109
  - **Instrumentation under the public API.** The seam sits in the transport
@@ -38,6 +38,15 @@ handle = inspect(client) # what actually got applied
38
38
  print(handle.report.dropped) # config the adapter could not express
39
39
  ```
40
40
 
41
+ > [!TIP]
42
+ > **Building this with an AI assistant?** Hand it
43
+ > **[one page](https://bedrock-python.github.io/clientwright/agents/)** instead of the
44
+ > whole site: the whole public API with its real defaults, where the boundary between the
45
+ > core engine and an adapter runs, the rules that break code when they are broken, the
46
+ > mistakes models actually make with this API, and a map of which page to fetch for the
47
+ > rest. Every docs page is also served as raw Markdown at its own URL, and a **Copy page**
48
+ > button at the top of each one hands it straight to a chat window.
49
+
41
50
  ## Why
42
51
 
43
52
  - **Instrumentation under the public API.** The seam sits in the transport
@@ -52,6 +52,7 @@ from .core.contracts import (
52
52
  ClientSettingsProtocol,
53
53
  DeadlineSource,
54
54
  HeaderProvider,
55
+ MaskerProtocol,
55
56
  RetrySettingsProtocol,
56
57
  SpanProtocol,
57
58
  TracerProtocol,
@@ -73,6 +74,7 @@ from .core.model import IDEMPOTENT_METHODS, CircuitKey, FailureKind, Outcome, Re
73
74
  from .core.options import CallOptions, call_options, current_call_options
74
75
  from .core.plan import ClientHandle, ClientRuntime, inspect_client
75
76
  from .core.registry import register_adapter, registered_adapters, resolve_adapter
77
+ from .core.telemetry.redaction import redact_headers
76
78
 
77
79
  # The builders are typed ``Any`` deliberately. The whole product is "you get the
78
80
  # REAL native client", and the core cannot name ``httpx.AsyncClient`` without
@@ -136,6 +138,7 @@ __all__ = [
136
138
  "DurationBoundary",
137
139
  "FailureKind",
138
140
  "HeaderProvider",
141
+ "MaskerProtocol",
139
142
  "Maybe",
140
143
  "NativeConfigError",
141
144
  "NativeOptions",
@@ -173,6 +176,7 @@ __all__ = [
173
176
  "inspect",
174
177
  "inspect_client",
175
178
  "is_set",
179
+ "redact_headers",
176
180
  "register_adapter",
177
181
  "registered_adapters",
178
182
  "resolve_adapter",
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1" # x-release-please-version
@@ -30,7 +30,7 @@ from ..core.engine.aio import AsyncAttemptEngine
30
30
  from ..core.engine.sync import SyncAttemptEngine
31
31
  from ..core.errors import CallError, CircuitOpenError, DeadlineExceededError, TooManyRedirectsError
32
32
  from ..core.model import IDEMPOTENT_METHODS, ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of
33
- from ..core.native import validate_native
33
+ from ..core.native import accepted_overrides, validate_native
34
34
  from ..core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
35
35
  from ..core.policy.timeout import base_timeouts
36
36
  from ..core.telemetry.emitter import ClientTelemetry
@@ -622,7 +622,7 @@ class FamilyAdapter:
622
622
  config_conflicts={},
623
623
  )
624
624
 
625
- def _compile(self, config: ClientConfig, *, sync: bool) -> CallPlan:
625
+ def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]], *, sync: bool) -> CallPlan:
626
626
  applied = {
627
627
  Capability.TIMEOUT_CONNECT,
628
628
  Capability.TIMEOUT_READ,
@@ -657,6 +657,7 @@ class FamilyAdapter:
657
657
  applied_natively=frozenset(applied),
658
658
  emulated=frozenset(emulated),
659
659
  dropped=dropped,
660
+ native_overrides=accepted_overrides(native),
660
661
  )
661
662
  plan.report.enforce(config.on_unsupported)
662
663
  return plan
@@ -699,7 +700,7 @@ class FamilyAdapter:
699
700
  runtime = deps.runtime or ClientRuntime.for_config(
700
701
  config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
701
702
  )
702
- plan = self._compile(config, sync=False)
703
+ plan = self._compile(config, native, sync=False)
703
704
  base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS)
704
705
  engine = AsyncAttemptEngine(
705
706
  plan=plan,
@@ -756,7 +757,7 @@ class FamilyAdapter:
756
757
  runtime = deps.runtime or ClientRuntime.for_config(
757
758
  config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
758
759
  )
759
- plan = self._compile(config, sync=True)
760
+ plan = self._compile(config, native, sync=True)
760
761
  base = base_timeouts(config.timeout, NATIVE_TIMEOUT_DEFAULTS)
761
762
  engine = SyncAttemptEngine(
762
763
  plan=plan,
@@ -24,7 +24,7 @@ from ...core.contracts.adapter import AdapterDeps
24
24
  from ...core.engine.aio import AsyncAttemptEngine
25
25
  from ...core.errors import UnsupportedCapabilityError
26
26
  from ...core.model import ResolvedTimeouts
27
- from ...core.native import validate_native
27
+ from ...core.native import accepted_overrides, validate_native
28
28
  from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
29
29
  from ...core.policy.timeout import base_timeouts
30
30
  from ...core.telemetry.emitter import ClientTelemetry
@@ -116,7 +116,7 @@ class AiohttpAdapter:
116
116
  config_conflicts={},
117
117
  )
118
118
 
119
- def _compile(self, config: ClientConfig) -> CallPlan:
119
+ def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan:
120
120
  applied = {
121
121
  Capability.TIMEOUT_CONNECT,
122
122
  Capability.TIMEOUT_READ,
@@ -146,6 +146,7 @@ class AiohttpAdapter:
146
146
  applied_natively=frozenset(applied),
147
147
  emulated=frozenset(emulated),
148
148
  dropped=dropped,
149
+ native_overrides=accepted_overrides(native),
149
150
  )
150
151
  plan.report.enforce(config.on_unsupported)
151
152
  return plan
@@ -179,7 +180,7 @@ class AiohttpAdapter:
179
180
  runtime = deps.runtime or ClientRuntime.for_config(
180
181
  config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
181
182
  )
182
- plan = self._compile(config)
183
+ plan = self._compile(config, native)
183
184
  base = base_timeouts(config.timeout, _NATIVE_TIMEOUT_DEFAULTS)
184
185
  engine = AsyncAttemptEngine(
185
186
  plan=plan,
@@ -25,7 +25,7 @@ from ...core.engine.suppress import is_suppressed, suppressed
25
25
  from ...core.engine.sync import SyncAttemptEngine
26
26
  from ...core.errors import UnsupportedCapabilityError
27
27
  from ...core.model import ResolvedTimeouts
28
- from ...core.native import validate_native
28
+ from ...core.native import accepted_overrides, validate_native
29
29
  from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
30
30
  from ...core.telemetry.emitter import ClientTelemetry
31
31
  from ._imports import HTTPAdapter, requests, urllib3
@@ -111,7 +111,7 @@ class RequestsAdapter:
111
111
  config_conflicts={},
112
112
  )
113
113
 
114
- def _compile(self, config: ClientConfig) -> CallPlan:
114
+ def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan:
115
115
  applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE}
116
116
  if resolve(config.pool.max_connections_per_host, None) is not None:
117
117
  applied.add(Capability.POOL_LIMIT_PER_HOST)
@@ -136,6 +136,7 @@ class RequestsAdapter:
136
136
  applied_natively=frozenset(applied),
137
137
  emulated=frozenset(emulated),
138
138
  dropped=dropped,
139
+ native_overrides=accepted_overrides(native),
139
140
  )
140
141
  plan.report.enforce(config.on_unsupported)
141
142
  return plan
@@ -155,7 +156,7 @@ class RequestsAdapter:
155
156
  runtime = deps.runtime or ClientRuntime.for_config(
156
157
  config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
157
158
  )
158
- plan = self._compile(config)
159
+ plan = self._compile(config, native)
159
160
  engine = SyncAttemptEngine(
160
161
  plan=plan,
161
162
  runtime=runtime,
@@ -24,7 +24,7 @@ from ...core.engine.suppress import is_suppressed, suppressed
24
24
  from ...core.engine.sync import SyncAttemptEngine
25
25
  from ...core.errors import UnsupportedCapabilityError
26
26
  from ...core.model import ResolvedTimeouts
27
- from ...core.native import validate_native
27
+ from ...core.native import accepted_overrides, validate_native
28
28
  from ...core.plan import CallPlan, ClientHandle, ClientRuntime, compile_plan, register_handle
29
29
  from ...core.policy.timeout import base_timeouts
30
30
  from ...core.telemetry.emitter import ClientTelemetry
@@ -141,7 +141,7 @@ class Urllib3Adapter:
141
141
  config_conflicts={},
142
142
  )
143
143
 
144
- def _compile(self, config: ClientConfig) -> CallPlan:
144
+ def _compile(self, config: ClientConfig, native: Mapping[str, Mapping[str, Any]]) -> CallPlan:
145
145
  per_host = resolve(config.pool.max_connections_per_host, None)
146
146
  applied = {Capability.TIMEOUT_CONNECT, Capability.TIMEOUT_READ, Capability.REDIRECTS_OWNABLE}
147
147
  if per_host is not None:
@@ -172,6 +172,7 @@ class Urllib3Adapter:
172
172
  applied_natively=frozenset(applied),
173
173
  emulated=frozenset(emulated),
174
174
  dropped=dropped,
175
+ native_overrides=accepted_overrides(native),
175
176
  )
176
177
  plan.report.enforce(config.on_unsupported)
177
178
  return plan
@@ -215,7 +216,7 @@ class Urllib3Adapter:
215
216
  runtime = deps.runtime or ClientRuntime.for_config(
216
217
  config, clock=deps.clock, circuit_listener=telemetry.circuit_state_changed
217
218
  )
218
- plan = self._compile(config)
219
+ plan = self._compile(config, native)
219
220
  engine = SyncAttemptEngine(
220
221
  plan=plan,
221
222
  runtime=runtime,
@@ -15,10 +15,13 @@ import logging
15
15
  from collections.abc import Mapping
16
16
  from dataclasses import dataclass, field
17
17
  from enum import StrEnum
18
- from typing import Final
18
+ from typing import TYPE_CHECKING, Final
19
19
 
20
20
  from .model import IDEMPOTENT_METHODS, CircuitKey, FailureKind
21
21
 
22
+ if TYPE_CHECKING:
23
+ from .contracts.observability import MaskerProtocol
24
+
22
25
 
23
26
  class _Unset:
24
27
  """Singleton sentinel distinguishing "user set this" from "default applies"."""
@@ -106,6 +109,9 @@ DEFAULT_TRIP_KINDS: Final = frozenset(
106
109
  }
107
110
  )
108
111
 
112
+ # Not a config knob: clientwright never emits headers into logs or spans, so
113
+ # there is nothing for a header list to protect. Kept as a public default for
114
+ # services that log headers themselves via ``redact_headers``.
109
115
  DEFAULT_SENSITIVE_HEADERS: Final = frozenset(
110
116
  {
111
117
  "authorization",
@@ -261,14 +267,26 @@ _INFO_LEVEL: Final = logging.INFO
261
267
 
262
268
  @dataclass(frozen=True, slots=True)
263
269
  class ObservabilityConfig:
264
- """Which telemetry channels are active; only knobs that actually work exist here."""
270
+ """Which telemetry channels are active; only knobs that actually work exist here.
271
+
272
+ Two-stage URL scrubbing before anything reaches a log line or a span:
273
+ ``sensitive_query_params`` redacts by parameter *name*, then ``url_masker``
274
+ (if set) sees the whole redacted URL and may scrub PII by *value* - the
275
+ email in a path segment that no name list can catch. See
276
+ :class:`~clientwright.core.contracts.observability.MaskerProtocol` for the
277
+ failure contract.
278
+ """
265
279
 
266
280
  logging: bool = True
267
281
  metrics: bool = True
268
282
  tracing: bool = True
269
283
  success_log_level: int = _INFO_LEVEL
270
- sensitive_headers: frozenset[str] = DEFAULT_SENSITIVE_HEADERS
271
284
  sensitive_query_params: frozenset[str] = DEFAULT_SENSITIVE_QUERY_PARAMS
285
+ url_masker: MaskerProtocol | None = None
286
+
287
+ def __post_init__(self) -> None:
288
+ if self.url_masker is not None and not callable(self.url_masker):
289
+ raise ValueError(f"observability.url_masker must be callable or None, got {self.url_masker!r}")
272
290
 
273
291
 
274
292
  @dataclass(frozen=True, slots=True)
@@ -9,6 +9,7 @@ from .message import RequestView as RequestView
9
9
  from .message import ResponseView as ResponseView
10
10
  from .message import SyncNormalizer as SyncNormalizer
11
11
  from .observability import ClientMetricsProtocol as ClientMetricsProtocol
12
+ from .observability import MaskerProtocol as MaskerProtocol
12
13
  from .observability import SpanProtocol as SpanProtocol
13
14
  from .observability import TracerProtocol as TracerProtocol
14
15
  from .settings import CircuitBreakerSettingsProtocol as CircuitBreakerSettingsProtocol
@@ -24,6 +25,7 @@ __all__ = [
24
25
  "ClientSettingsProtocol",
25
26
  "DeadlineSource",
26
27
  "HeaderProvider",
28
+ "MaskerProtocol",
27
29
  "RequestView",
28
30
  "ResponseView",
29
31
  "RetrySettingsProtocol",
@@ -69,6 +69,24 @@ class ClientMetricsProtocol(Protocol):
69
69
  def record_uninstrumented_call(self, *, service: str, adapter: str, seam: str) -> None: ...
70
70
 
71
71
 
72
+ @runtime_checkable
73
+ class MaskerProtocol(Protocol):
74
+ """Value-level masker applied to telemetry strings before they are emitted.
75
+
76
+ The key-based redaction (``sensitive_query_params``) runs first; the masker
77
+ then sees the whole string and may scrub PII that key lists cannot reach -
78
+ an email in a path segment, a card number in a free-form value. Any
79
+ ``(str) -> str`` callable qualifies: a regex substitution, a
80
+ format-preserving masker, a locally hosted NER model.
81
+
82
+ Contract: synchronous, and cheap enough to run 2-3 times per HTTP call.
83
+ A raising masker never breaks the request - the emitter replaces the whole
84
+ value with ``[redacted]`` (fail closed) and warns once per client.
85
+ """
86
+
87
+ def __call__(self, value: str) -> str: ...
88
+
89
+
72
90
  @runtime_checkable
73
91
  class SpanProtocol(Protocol):
74
92
  """One client span; ended exactly once, in a finally block."""
@@ -89,4 +107,4 @@ class TracerProtocol(Protocol):
89
107
  def inject_context(self, headers: MutableMapping[str, str]) -> None: ...
90
108
 
91
109
 
92
- __all__ = ["ClientMetricsProtocol", "SpanProtocol", "TracerProtocol"]
110
+ __all__ = ["ClientMetricsProtocol", "MaskerProtocol", "SpanProtocol", "TracerProtocol"]
@@ -214,10 +214,11 @@ class AsyncAttemptEngine:
214
214
  duration=runtime.clock() - attempt_started,
215
215
  outcome=outcome,
216
216
  hop=observation.hops,
217
+ conn=self._norm.conn_metrics(response) if response is not None else None,
217
218
  )
218
219
  history.append(attempt)
219
220
  if plan.emit_attempt_metrics:
220
- self._telemetry.attempt_end(info, attempt)
221
+ self._telemetry.attempt_end(observation, info, attempt)
221
222
  if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired:
222
223
  raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception
223
224
  if plan.retry_policy is None:
@@ -209,10 +209,11 @@ class SyncAttemptEngine:
209
209
  duration=runtime.clock() - attempt_started,
210
210
  outcome=outcome,
211
211
  hop=observation.hops,
212
+ conn=self._norm.conn_metrics(response) if response is not None else None,
212
213
  )
213
214
  history.append(attempt)
214
215
  if plan.emit_attempt_metrics:
215
- self._telemetry.attempt_end(info, attempt)
216
+ self._telemetry.attempt_end(observation, info, attempt)
216
217
  if outcome.kind is FailureKind.TOTAL_TIMEOUT and deadline.expired:
217
218
  raise DeadlineExceededError(deadline.total or 0.0) from outcome.exception
218
219
  if plan.retry_policy is None:
@@ -93,7 +93,15 @@ class TooManyRedirectsError(CallError):
93
93
 
94
94
 
95
95
  class NotReplayableError(CallError):
96
- """The request body cannot be replayed, so the required repeat is impossible."""
96
+ """A body that cannot be replayed made a required repeat impossible.
97
+
98
+ The engine never raises this. A non-replayable body ends the call with the
99
+ response it already has plus a ``retry_skipped{reason="non_replayable"}``
100
+ counter, so ``except NotReplayableError`` around a call never fires. It is
101
+ part of the public ``CallError`` family for adapters and callers that choose
102
+ to make that refusal fatal themselves; the adapter translators pass it
103
+ through unchanged rather than dressing it in an SDK error class.
104
+ """
97
105
 
98
106
 
99
107
  __all__ = [
@@ -81,4 +81,9 @@ def validate_native(
81
81
  return validated
82
82
 
83
83
 
84
- __all__ = ["validate_native"]
84
+ def accepted_overrides(validated: Mapping[str, Mapping[str, object]]) -> dict[str, tuple[str, ...]]:
85
+ """Report shape of validated passthrough: slot -> the keys that survived validation."""
86
+ return {slot: tuple(sorted(values)) for slot, values in validated.items() if values}
87
+
88
+
89
+ __all__ = ["accepted_overrides", "validate_native"]
@@ -11,7 +11,7 @@ from dataclasses import dataclass
11
11
  from random import Random
12
12
 
13
13
  from ..config import RetryConfig
14
- from ..model import Attempt, FailureKind, RequestInfo
14
+ from ..model import IDEMPOTENT_METHODS, Attempt, FailureKind, RequestInfo
15
15
 
16
16
  # A retry whose backoff would land this close to the deadline is pointless.
17
17
  _DEADLINE_SLACK = 0.001
@@ -40,6 +40,20 @@ class DefaultRetryPolicy:
40
40
  return f"kind_{kind.value}"
41
41
  return None
42
42
 
43
+ def _method_allows_retry(self, info: RequestInfo) -> bool:
44
+ """Two vetoes on repeating this method, either of which is enough.
45
+
46
+ ``RequestInfo.idempotent`` restates the method's RFC default unless the
47
+ CALL SITE overrode it, so a flag disagreeing with ``IDEMPOTENT_METHODS``
48
+ is the call site talking and decides on its own - that is what makes
49
+ ``idempotent=True`` unlock a POST and ``idempotent=False`` veto a GET.
50
+ A flag that only restates the default leaves the decision with the
51
+ operator's ``retry.methods``.
52
+ """
53
+ if info.idempotent != (info.method in IDEMPOTENT_METHODS):
54
+ return info.idempotent
55
+ return info.method in self._config.methods
56
+
43
57
  def _backoff(self, attempt_index: int, retry_after: float | None, rng: Random) -> float:
44
58
  config = self._config
45
59
  if config.respect_retry_after and retry_after is not None:
@@ -67,7 +81,7 @@ class DefaultRetryPolicy:
67
81
  return RetryDecision(retry=False, reason="final")
68
82
  if len(history) >= config.max_attempts:
69
83
  return RetryDecision(retry=False, reason="attempts")
70
- if info.method not in config.methods and not info.idempotent:
84
+ if not self._method_allows_retry(info):
71
85
  return RetryDecision(retry=False, reason="method")
72
86
  if config.require_replayable_body and not replayable:
73
87
  return RetryDecision(retry=False, reason="non_replayable")
@@ -12,10 +12,10 @@ from dataclasses import dataclass
12
12
 
13
13
  from ..config import ObservabilityConfig
14
14
  from ..contracts.observability import ClientMetricsProtocol, SpanProtocol, TracerProtocol
15
- from ..model import Attempt, Outcome, RequestInfo
15
+ from ..model import Attempt, ConnMetrics, Outcome, RequestInfo
16
16
  from .names import OUTCOME_SUCCESS, ROUTE_UNKNOWN, STATUS_NONE
17
17
  from .null import NullMetrics, NullTracer
18
- from .redaction import redact_url
18
+ from .redaction import REDACTED, redact_url
19
19
 
20
20
 
21
21
  def outcome_label(outcome: Outcome) -> str:
@@ -57,13 +57,26 @@ class ClientTelemetry:
57
57
  self._tracer: TracerProtocol = tracer if (tracer and config.tracing) else NullTracer()
58
58
  self._logger = logging.getLogger(f"clientwright.client.{service}")
59
59
  self._log_enabled = config.logging
60
+ self._masker_warned = False
60
61
 
61
62
  @property
62
63
  def tracer(self) -> TracerProtocol:
63
64
  return self._tracer
64
65
 
65
66
  def _safe_url(self, info: RequestInfo) -> str:
66
- return redact_url(info.url, self._config.sensitive_query_params)
67
+ url = redact_url(info.url, self._config.sensitive_query_params)
68
+ masker = self._config.url_masker
69
+ if masker is None:
70
+ return url
71
+ # Fail closed: a broken masker must not publish the raw URL, and must
72
+ # not break the request either. Warn once per client, not per call.
73
+ try:
74
+ return masker(url)
75
+ except Exception:
76
+ if not self._masker_warned:
77
+ self._masker_warned = True
78
+ self._logger.warning("url_masker raised; emitting [redacted] instead of the URL", exc_info=True)
79
+ return REDACTED
67
80
 
68
81
  def call_start(self, info: RequestInfo, started: float) -> CallObservation:
69
82
  span = self._tracer.start_span(
@@ -84,7 +97,7 @@ class ClientTelemetry:
84
97
  )
85
98
  return CallObservation(span=span, started=started)
86
99
 
87
- def attempt_end(self, info: RequestInfo, attempt: Attempt) -> None:
100
+ def attempt_end(self, observation: CallObservation, info: RequestInfo, attempt: Attempt) -> None:
88
101
  self._metrics.record_attempt(
89
102
  service=self._service,
90
103
  adapter=self._adapter,
@@ -94,6 +107,25 @@ class ClientTelemetry:
94
107
  outcome=outcome_label(attempt.outcome),
95
108
  duration=attempt.duration,
96
109
  )
110
+ if attempt.conn is not None:
111
+ self._record_conn(observation.span, attempt.conn)
112
+
113
+ def _record_conn(self, span: SpanProtocol, conn: ConnMetrics) -> None:
114
+ """Connection timings onto the call span; the last attempt that saw them wins.
115
+
116
+ The metric families are a frozen contract with no room for them, so the
117
+ span is where an adapter that can observe them surfaces them.
118
+ """
119
+ for key, value in (
120
+ ("http.connection.dns_duration", conn.dns),
121
+ ("http.connection.connect_duration", conn.connect),
122
+ ("http.connection.tls_duration", conn.tls),
123
+ ("http.connection.pool_wait_duration", conn.pool_wait),
124
+ ("http.connection.reused", conn.reused),
125
+ ("network.protocol.version", conn.http_version),
126
+ ):
127
+ if value is not None:
128
+ span.set_attribute(key, value)
97
129
 
98
130
  def redirect_hop(self, observation: CallObservation) -> None:
99
131
  observation.hops += 1
@@ -22,10 +22,11 @@ from clientwright.core.capabilities import (
22
22
  )
23
23
  from clientwright.core.config import ClientConfig, RetryConfig
24
24
  from clientwright.core.contracts.adapter import AdapterDeps
25
+ from clientwright.core.contracts.observability import TracerProtocol
25
26
  from clientwright.core.engine.aio import AsyncAttemptEngine
26
27
  from clientwright.core.engine.base import default_response_outcome
27
28
  from clientwright.core.engine.sync import SyncAttemptEngine
28
- from clientwright.core.model import FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of
29
+ from clientwright.core.model import ConnMetrics, FailureKind, Outcome, RequestInfo, ResolvedTimeouts, origin_of
29
30
  from clientwright.core.plan import ClientRuntime, compile_plan
30
31
  from clientwright.core.telemetry.emitter import ClientTelemetry
31
32
  from clientwright.core.testing import RecordingMetrics
@@ -182,12 +183,13 @@ def as_async_send(script: ScriptedSend) -> Callable[[EngineRequest], Any]:
182
183
  class FakeNormalizer:
183
184
  """Both normalizer flavors in one object; the request/response ARE the views."""
184
185
 
185
- def __init__(self, *, freeze_ok: bool = True) -> None:
186
+ def __init__(self, *, freeze_ok: bool = True, conn: ConnMetrics | None = None) -> None:
186
187
  self.freeze_ok = freeze_ok
187
188
  self.freezes = 0
188
189
  self.rewinds = 0
189
190
  self.discards = 0
190
191
  self.wrapped_streams = 0
192
+ self.conn = conn
191
193
 
192
194
  def wrap_request(self, native: Any) -> EngineRequest:
193
195
  assert isinstance(native, EngineRequest)
@@ -212,8 +214,8 @@ class FakeNormalizer:
212
214
  def wrap_stream(self, response: FakeResponse, on_done: Callable[[Outcome, float], None]) -> None:
213
215
  self.wrapped_streams += 1
214
216
 
215
- def conn_metrics(self, response: FakeResponse) -> None:
216
- return None
217
+ def conn_metrics(self, response: FakeResponse) -> ConnMetrics | None:
218
+ return self.conn
217
219
 
218
220
  # -- sync flavor -------------------------------------------------------
219
221
 
@@ -261,6 +263,8 @@ class Harness:
261
263
  freeze_ok: bool = True,
262
264
  sync: bool = False,
263
265
  clock: Callable[[], float] | None = None,
266
+ tracer: TracerProtocol | None = None,
267
+ conn: ConnMetrics | None = None,
264
268
  ) -> None:
265
269
  self.config = config
266
270
  self.metrics = RecordingMetrics()
@@ -273,11 +277,11 @@ class Harness:
273
277
  seam="test",
274
278
  config=config.observability,
275
279
  metrics=self.metrics,
276
- tracer=None,
280
+ tracer=tracer,
277
281
  )
278
282
  self.normalizer: FakeSyncNormalizer | FakeAsyncNormalizer
279
283
  if sync:
280
- self.normalizer = FakeSyncNormalizer(freeze_ok=freeze_ok)
284
+ self.normalizer = FakeSyncNormalizer(freeze_ok=freeze_ok, conn=conn)
281
285
  self.engine: Any = SyncAttemptEngine(
282
286
  plan=self.plan,
283
287
  runtime=self.runtime,
@@ -287,7 +291,7 @@ class Harness:
287
291
  translate=lambda error: error,
288
292
  )
289
293
  else:
290
- self.normalizer = FakeAsyncNormalizer(freeze_ok=freeze_ok)
294
+ self.normalizer = FakeAsyncNormalizer(freeze_ok=freeze_ok, conn=conn)
291
295
  self.engine = AsyncAttemptEngine(
292
296
  plan=self.plan,
293
297
  runtime=self.runtime,
@@ -0,0 +1,41 @@
1
+ """Telemetry doubles: a tracer that keeps every span it opened."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, MutableMapping
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class RecordingSpan:
11
+ """SpanProtocol double keeping every attribute the emitter sets."""
12
+
13
+ attributes: dict[str, object] = field(default_factory=dict)
14
+ ended: bool = False
15
+
16
+ def set_attribute(self, key: str, value: str | int | float | bool) -> None:
17
+ self.attributes[key] = value
18
+
19
+ def record_failure(self, description: str) -> None:
20
+ self.attributes["failure"] = description
21
+
22
+ def end(self) -> None:
23
+ self.ended = True
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class RecordingTracer:
28
+ """TracerProtocol double; one RecordingSpan per logical call."""
29
+
30
+ spans: list[RecordingSpan] = field(default_factory=list)
31
+
32
+ def start_span(self, name: str, *, attributes: Mapping[str, str | int | float | bool]) -> RecordingSpan:
33
+ span = RecordingSpan(attributes=dict(attributes))
34
+ self.spans.append(span)
35
+ return span
36
+
37
+ def inject_context(self, headers: MutableMapping[str, str]) -> None:
38
+ return
39
+
40
+
41
+ __all__ = ["RecordingSpan", "RecordingTracer"]