cloudru-qa-http-client-structlog 1.0.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.
@@ -0,0 +1,168 @@
1
+ Metadata-Version: 2.5
2
+ Name: cloudru-qa-http-client-structlog
3
+ Version: 1.0.0
4
+ Summary: Структурированные логи для HTTP-клиента
5
+ Project-URL: Repository, https://github.com/cloud-ru-tech/qa-libraries
6
+ Project-URL: Issues, https://github.com/cloud-ru-tech/qa-libraries/issues
7
+ Author-email: Алексей Кирютин <ayukiryutin@cloud.ru>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Internet :: WWW/HTTP
16
+ Classifier: Topic :: System :: Logging
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.13
19
+ Requires-Dist: cloudru-qa-http-client<2,>=1.0.0
20
+ Requires-Dist: structlog>=26.1.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Structured logs for the HTTP client
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/cloudru-qa-http-client-structlog)](https://pypi.org/project/cloudru-qa-http-client-structlog/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/cloudru-qa-http-client-structlog)](https://pypi.org/project/cloudru-qa-http-client-structlog/)
27
+ [![License](https://img.shields.io/pypi/l/cloudru-qa-http-client-structlog)](https://github.com/cloud-ru-tech/qa-libraries/blob/master/LICENSE)
28
+
29
+ English | [Русский](README.ru.md)
30
+
31
+ The `with_structlogger` option for the [HTTP client](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/http-client)
32
+ logs all requests and responses through [structlog](https://www.structlog.org/en/stable/index.html).
33
+
34
+ The standard http-client logger puts the request in one text string. This option logs the method, URL,
35
+ headers, body, and other data as separate fields, visible in the console during debugging
36
+ and available for searching and filtering in collected logs.
37
+
38
+ - [Installation](#installation)
39
+ - [Skills for AI agents](#skills-for-ai-agents)
40
+ - [Usage](#usage)
41
+ - [Asynchronous client](#asynchronous-client)
42
+ - [Masking secrets](#masking-secrets)
43
+ - [Parameters](#parameters)
44
+ - [Development](#development)
45
+
46
+ ## Installation
47
+
48
+ Requires Python 3.13 or later.
49
+
50
+ ```shell
51
+ pip install cloudru-qa-http-client-structlog
52
+ ```
53
+
54
+ Import the library as `http_client_structlog`. A compatible version of
55
+ [`cloudru-qa-http-client`](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/http-client)
56
+ is installed with it.
57
+
58
+ ## Skills for AI agents
59
+
60
+ The package includes the [`http-client-structlog-usage`](https://github.com/cloud-ru-tech/qa-libraries/blob/master/.agents/skills/http-client-structlog-usage/SKILL.md) skill
61
+ with examples and usage rules for AI agents. Install the library
62
+ in your project environment, then run this from its root (requires [`uv`](https://docs.astral.sh/uv/)):
63
+
64
+ ```shell
65
+ uvx library-skills install --skill http-client-structlog-usage --yes
66
+ ```
67
+
68
+ For Claude Code, add `--claude`. The instructions are shipped with the library version.
69
+
70
+ ## Usage
71
+
72
+ Pass the option to the client constructor; logging is then automatic:
73
+
74
+ ```python
75
+ import structlog
76
+
77
+ from http_client import HttpClient
78
+ from http_client_structlog import with_structlogger
79
+
80
+
81
+ class OrdersClient(HttpClient):
82
+ def get_order(self, order_id: str):
83
+ request = self.client.build_request("GET", f"/orders/{order_id}")
84
+ return self.send(request)
85
+
86
+
87
+ with OrdersClient("https://api.example.com", with_structlogger(structlog.get_logger("orders"))) as client:
88
+ client.get_order("42")
89
+ ```
90
+
91
+ The request is logged as an `http request sent` event, the response as `http response received`.
92
+ A shared `trace_id` field links them.
93
+
94
+ Structured logs for automated tests can look like this:
95
+
96
+ ![structlogs](https://raw.githubusercontent.com/cloud-ru-tech/qa-libraries/master/packages/http-client-structlog/assets/image.png)
97
+
98
+ ## Asynchronous client
99
+
100
+ Use the same option without extra configuration:
101
+
102
+ ```python
103
+ from http_client import AsyncHttpClient
104
+
105
+
106
+ class AsyncOrdersClient(AsyncHttpClient):
107
+ async def get_order(self, order_id: str):
108
+ request = self.client.build_request("GET", f"/orders/{order_id}")
109
+ return await self.send(request)
110
+
111
+
112
+ async with AsyncOrdersClient("https://api.example.com", with_structlogger(logger)) as client:
113
+ await client.get_order("42")
114
+ ```
115
+
116
+ ## Masking secrets
117
+
118
+ Fields with common secret names — `authorization`, `cookie`, `token`, `password`, `api_key`, and others
119
+ from `http_client.event_hooks.DEFAULT_SANITIZE_KEYS` — are masked **by default at every depth**,
120
+ including nested objects and array items.
121
+
122
+ Add your own fields with `sanitize_keys`. These are exact paths in
123
+ [glom](https://glom.readthedocs.io/en/latest/) format, applied to headers, query parameters,
124
+ and the JSON body. They extend the default list rather than replacing it:
125
+
126
+ ```python
127
+ with_structlogger(
128
+ logger,
129
+ sanitize_keys=["user.password", "items.*.token"],
130
+ )
131
+ ```
132
+
133
+ Write header names in lowercase: `"authorization"`, not `"Authorization"`.
134
+
135
+ Disable the default list explicitly with `use_default_sanitize_keys=False`.
136
+
137
+ ## Parameters
138
+
139
+ | Parameter | Type | Purpose |
140
+ |---|---|---|
141
+ | `logger` | `structlog.stdlib.BoundLogger` | where to write |
142
+ | `sanitize_keys` | `Sequence[str] \| None` | glom paths to fields replaced with `****` |
143
+ | `json_indent` | `int \| None` | indentation width for JSON properties |
144
+ | `log_level` | `int \| None` | record level; `INFO` by default |
145
+ | `use_default_sanitize_keys` | `bool` | mask common secrets at every depth; `True` by default |
146
+
147
+ Set the level and indentation for the whole project with `http_client.configure_logging(...)`
148
+ or `HTTP_CLIENT_*` environment variables. Values passed to `with_structlogger` override shared settings.
149
+
150
+ ## Development
151
+
152
+ The library is developed in the [qa-libraries](https://github.com/cloud-ru-tech/qa-libraries) monorepo
153
+ alongside the other `cloudru-qa-*` libraries. You need [`uv`](https://docs.astral.sh/uv/), GNU Make,
154
+ and [`checkmake`](https://github.com/checkmake/checkmake).
155
+ Run commands from the repository root:
156
+
157
+ ```shell
158
+ make sync # install dependencies for all libraries from uv.lock
159
+ make test-http-client-structlog # tests for this library
160
+ make typecheck-http-client-structlog # mypy for this library
161
+ make check # formatting, ruff, mypy, and tests for the entire workspace
162
+ ```
163
+
164
+ Tests run on Python 3.13 and 3.14. Create a separate environment for another version:
165
+ `make sync test-http-client-structlog PYTHON_VERSION=3.14`.
166
+
167
+ Publish a release with the git tag `http-client-structlog/v<version>`. The tag version must match
168
+ the version in `packages/http-client-structlog/pyproject.toml`.
@@ -0,0 +1,13 @@
1
+ http_client_structlog/__init__.py,sha256=JFrlU8OlVVPWgB-8J_Zv4m7oTWzWMBlv7q3GHBqpusU,71
2
+ http_client_structlog/event_hooks.py,sha256=YrDHzVCRU_kMowOblESTZUrykpQTuKho3VbHycXlSTw,2429
3
+ http_client_structlog/option.py,sha256=81M4dNSxhzM59F-mObXKvSVTzorvlRYVYNYyPWvj04g,2131
4
+ http_client_structlog/parser.py,sha256=-NmAu7LNNEeyY_EGsXdx0ZmnVaG0WGk37ISJvd9ybvc,6177
5
+ http_client_structlog/py.typed,sha256=2V-BXHpFdhiPP7ymH5gngkCEyIkBSKRBl5jxiG1YHEM,81
6
+ http_client_structlog/sanitizer.py,sha256=9yQtmIg1xq70kgzaOAjT41Jip9CiyLOWnRpqMDa4hRY,1984
7
+ http_client_structlog/.agents/skills/http-client-structlog-usage/SKILL.md,sha256=vKvXpGC7meflakWcSUCGEdv8Aqb7cOOGswuhfCv1P_U,6552
8
+ http_client_structlog/.agents/skills/http-client-structlog-usage/references/fields.md,sha256=jkZz3NQn8yD7WmyFup4R_OJVlrdRIe7pQ8L04kP6Xqc,6127
9
+ http_client_structlog/.agents/skills/http-client-structlog-usage/references/pitfalls.md,sha256=LUgW2MYaxJdY_5lmTk47sKi1Mrb3G1n5mkjTr-VRmYE,5791
10
+ cloudru_qa_http_client_structlog-1.0.0.dist-info/METADATA,sha256=00TSXNDxY9JWhEPIAG1OUxAHVPPtP1NbQjuk1RioY-I,6680
11
+ cloudru_qa_http_client_structlog-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
12
+ cloudru_qa_http_client_structlog-1.0.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
13
+ cloudru_qa_http_client_structlog-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,202 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: http-client-structlog-usage
3
+ description: Structured request/response logging for the http_client library — the with_structlogger option that writes every HTTP call into structlog as separate fields instead of one text line, with secret masking on by default. Use when a project imports http_client_structlog, when wiring structlog into an HTTP client, or when tuning what ends up in the access log (fields, masking, level, JSON indent, streaming, async). Key names are with_structlogger, sanitize_keys, use_default_sanitize_keys, trace_id.
4
+ license: Apache-2.0
5
+ metadata:
6
+ library-version: "1.0.0"
7
+ source: packages/http-client-structlog
8
+ ---
9
+
10
+ # Structured logging for http_client
11
+
12
+ The library exports exactly one name: `with_structlogger`. It is a `ClientOption` that replaces
13
+ `http_client.options.with_logger`. Instead of one text line per call, every attribute — method, URL,
14
+ headers, body, status — becomes its own [structlog](https://www.structlog.org) field, so logs can be
15
+ filtered and searched by field rather than by substring.
16
+
17
+ Everything else — building the client, endpoints, timeouts, auth, decorators — is plain `http_client`.
18
+ This library adds no client of its own and no way to log outside of the option.
19
+
20
+ ## Hard rules
21
+
22
+ 1. Install **one** logging option. `with_structlogger` and `options.with_logger` each append their own
23
+ hooks; passing both logs every request twice.
24
+ 2. Pass it as a constructor option, exactly like the built-in ones. There is no post-construction setup.
25
+ 3. Requires `http-client` **2.1 or newer**. Earlier versions lack the public log helpers this library
26
+ imports, and the async client will fail on import.
27
+ 4. `sanitize_keys` holds **exact glom paths**; the default secret list works **by field name at any
28
+ depth**. They are two different mechanisms — see `references/fields.md` before adding a path.
29
+ 5. Write header names lowercase (`"authorization"`) — httpx lowercases them before the hook sees them.
30
+ 6. Do not turn off `use_default_sanitize_keys` to "clean up" the log. It unmasks passwords and tokens.
31
+
32
+ Before debugging odd output, read `references/pitfalls.md` — it covers the non-obvious behaviour.
33
+
34
+ ## Usage
35
+
36
+ ```python
37
+ import httpx
38
+ import structlog
39
+ from http_client import HttpClient
40
+ from http_client_structlog import with_structlogger
41
+
42
+
43
+ class OrdersClient(HttpClient):
44
+ def get_order(self, order_id: str) -> httpx.Response:
45
+ request = self.client.build_request("GET", f"/orders/{order_id}")
46
+ return self.send(request)
47
+
48
+
49
+ with OrdersClient("https://api.example.com", with_structlogger(structlog.get_logger("orders"))) as client:
50
+ client.get_order("42")
51
+ ```
52
+
53
+ Combines with any other option:
54
+
55
+ ```python
56
+ from http_client import options
57
+ from httpx import BasicAuth
58
+
59
+ client = OrdersClient(
60
+ "https://api.example.com",
61
+ options.with_auth(BasicAuth("admin", "PazzW0rD")),
62
+ options.with_timeout(30),
63
+ with_structlogger(logger, json_indent=2),
64
+ )
65
+ ```
66
+
67
+ ## Async client
68
+
69
+ Same option, same arguments. The right response hook — sync or async — is picked from the client type
70
+ inside the option, so nothing extra is configured:
71
+
72
+ ```python
73
+ from http_client import AsyncHttpClient
74
+
75
+
76
+ class AsyncOrdersClient(AsyncHttpClient):
77
+ async def get_order(self, order_id: str) -> httpx.Response:
78
+ request = self.client.build_request("GET", f"/orders/{order_id}")
79
+ return await self.send(request)
80
+
81
+
82
+ async with AsyncOrdersClient("https://api.example.com", with_structlogger(logger)) as client:
83
+ await client.get_order("42")
84
+ ```
85
+
86
+ ## What lands in the log
87
+
88
+ Two events per call, linked by `trace_id`:
89
+
90
+ | Field | Event | Value |
91
+ |---|---|---|
92
+ | `event` | both | `http request sent` / `http response received` |
93
+ | `method` | request | uppercase HTTP method |
94
+ | `url` | request | `scheme://host/path`, without the query string |
95
+ | `query_params` | request | JSON string; omitted when the URL has no query |
96
+ | `headers` | both | JSON string |
97
+ | `body` | both | rendered by `Content-Type`; omitted when there is no body |
98
+ | `status_code` | response | `int` |
99
+ | `trace_id` | both | UUID4 generated per request |
100
+
101
+ `status_code` is the only non-string field: composite values are serialized to JSON strings, not passed
102
+ as nested dicts. The full catalog — body rendering per content type, truncation, streaming placeholders,
103
+ parse errors — is in `references/fields.md`.
104
+
105
+ `trace_id` is an internal request↔response link stored in `request.extensions["trace_id"]`. It has
106
+ nothing to do with server-side tracing; for that use `options.with_autogenerated_traceparent()`.
107
+
108
+ ## Masking
109
+
110
+ Fields whose **name** is a common secret — `authorization`, `cookie`, `token`, `password`, `api_key`
111
+ and the rest of `http_client.event_hooks.DEFAULT_SANITIZE_KEYS` — are replaced with `****` by default,
112
+ **at any depth**, including inside nested objects and array elements.
113
+
114
+ Project-specific fields go into `sanitize_keys` as [glom](https://glom.readthedocs.io/en/latest/) paths.
115
+ They apply to headers, query parameters, and the JSON body, and they **add to** the default list rather
116
+ than replacing it:
117
+
118
+ ```python
119
+ with_structlogger(logger, sanitize_keys=["user.internal_id", "items.*.card_number"])
120
+ ```
121
+
122
+ | Path | What it masks |
123
+ |---|---|
124
+ | `x-tenant-key` | a header (lowercase name) |
125
+ | `user.internal_id` | a nested body field |
126
+ | `items.*.card_number` | a field in every array element |
127
+ | `*.owner.email` | a field when the body itself is an array |
128
+
129
+ A path that matches nothing is skipped silently. Masking never touches the response object the caller
130
+ gets back — only the copy that goes into the log.
131
+
132
+ ## Parameters
133
+
134
+ | Parameter | Type | Meaning |
135
+ |---|---|---|
136
+ | `logger` | `structlog.stdlib.BoundLogger` | where to write |
137
+ | `sanitize_keys` | `Sequence[str] \| None` | glom paths replaced with `****` |
138
+ | `json_indent` | `int \| None` | indent used when serializing JSON fields |
139
+ | `log_level` | `int \| None` | record level, defaults to `INFO` |
140
+ | `use_default_sanitize_keys` | `bool` | mask common secret names at any depth, default `True` |
141
+
142
+ `log_level` and `json_indent` fall back to the global `http_client` settings when omitted — set them
143
+ once for the whole project with `http_client.configure_logging(...)` or the `HTTP_CLIENT_*` environment
144
+ variables. An argument passed to `with_structlogger` wins over the global value.
145
+
146
+ ## References
147
+
148
+ - `references/fields.md` — the field catalog, body rendering rules, global defaults and env variables.
149
+ - `references/pitfalls.md` — JSON strings vs nested values, streaming, auth retries, typing of `logger`.
@@ -0,0 +1,131 @@
1
+ # Field catalog and configuration
2
+
3
+ Two records per HTTP call. Field sets differ; `headers`, `body` and `trace_id` are shared.
4
+
5
+ ## Request — `http request sent`
6
+
7
+ | Field | Type | Value |
8
+ |---|---|---|
9
+ | `method` | `str` | uppercase method |
10
+ | `url` | `str` | `scheme://host/path` — no query string, it lives in `query_params` |
11
+ | `query_params` | `str` | JSON object of the query; **absent** when the URL carries no query |
12
+ | `headers` | `str` | JSON object of the headers, after masking |
13
+ | `body` | `str` | rendered by `Content-Type`; **absent** when the request has no body |
14
+ | `trace_id` | `str` | UUID4 written into `request.extensions["trace_id"]` |
15
+
16
+ ## Response — `http response received`
17
+
18
+ | Field | Type | Value |
19
+ |---|---|---|
20
+ | `status_code` | `int` | HTTP status |
21
+ | `headers` | `str` | JSON object of the response headers, after masking |
22
+ | `body` | `str` | rendered by `Content-Type`; **absent** when the response has no body |
23
+ | `trace_id` | `str` | the value taken from the request; `<without trace_id>` if the request never passed the request hook |
24
+
25
+ `status_code` is the only field that is not a string. Composite values (headers, query, JSON body) are
26
+ serialized into JSON strings — see `pitfalls.md` for why that matters to the renderer.
27
+
28
+ A response is not logged at all when the request fails before a response exists — a connect error or
29
+ timeout raises out of `httpx` and only the request record remains. Catch those in the calling code;
30
+ the option does not log exceptions.
31
+
32
+ ## Body rendering
33
+
34
+ The body is chosen by the `Content-Type` header:
35
+
36
+ | `Content-Type` | `body` holds |
37
+ |---|---|
38
+ | `application/json` | the parsed, masked, re-serialized body |
39
+ | `text/plain` | the text, decoded using the `charset` parameter of the header |
40
+ | `application/octet-stream` | the decoded text, or `<binary content (N bytes)>` when it is not text |
41
+ | anything else | `<not logged, content-type: application/pdf>` |
42
+ | header absent, or empty body | the field is omitted entirely |
43
+
44
+ So a missing `body` means there was nothing to log; `<not logged, ...>` means there was a body and its
45
+ type is not rendered. Only `application/json` is masked — the other types are logged verbatim, so do
46
+ not put secrets into a `text/plain` body.
47
+
48
+ ### Truncation
49
+
50
+ Length limits come from the global `http_client` config and are applied per content type:
51
+
52
+ | Content type | Limit | Default |
53
+ |---|---|---|
54
+ | `application/json` | `json_log_maxlength` | unlimited |
55
+ | `text/plain` | `plain_text_log_maxlength` | unlimited |
56
+ | `application/octet-stream` | `octet_stream_log_maxlength` | 1024 characters |
57
+
58
+ Truncation cuts the **middle** and inserts `...`, keeping the head and tail — the start of a payload
59
+ and its end both stay visible. These limits cannot be set per client, only globally.
60
+
61
+ ### Streaming
62
+
63
+ | Case | `body` holds |
64
+ |---|---|
65
+ | response obtained via `stream()` / `stream_request()` and not yet read | `<streamed content>` |
66
+ | request with a streaming body (generator, file object) | `<streamed content>` |
67
+
68
+ Reading either one inside the hook would consume the stream before the caller gets it, so the
69
+ placeholder is by design, not a failure. Everything else about the record — status, headers, masking of
70
+ those headers — is logged normally.
71
+
72
+ ### Parse errors
73
+
74
+ Any failure while building a field is caught and replaced with `<Error parse: TypeError>` — the
75
+ exception class only. The message is dropped deliberately: it can carry fragments of the body or URL.
76
+
77
+ Each part is protected independently, so a broken glom path in `sanitize_keys` spoils `headers` while
78
+ `body` and the rest stay intact. The request itself never fails because of logging.
79
+
80
+ ## Masking mechanics
81
+
82
+ Two independent passes run over every value, path-based first, name-based second:
83
+
84
+ | Mechanism | Source | Matches |
85
+ |---|---|---|
86
+ | glom paths | `sanitize_keys` | the exact path only — `user.password` does not match `order.user.password` |
87
+ | secret names | `DEFAULT_SANITIZE_KEYS` | a field with that name at any depth, including inside arrays |
88
+
89
+ Both replace the value with `****`. Name matching is case-insensitive; glom paths are literal, and httpx
90
+ lowercases header names before the hook runs, so paths that point at headers must be lowercase too.
91
+
92
+ Default names: `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `api-key`,
93
+ `api_key`, `access_token`, `refresh_token`, `password`, `secret`, `token`.
94
+
95
+ Name-based masking replaces the whole value, so a nested object under a key named `token` disappears
96
+ entirely rather than being walked into.
97
+
98
+ ## Global defaults
99
+
100
+ `log_level` and `json_indent` fall back to `http_client.config.LOGGING_CONFIG` whenever they are not
101
+ passed to `with_structlogger`. Fill that singleton once at startup:
102
+
103
+ ```python
104
+ import logging
105
+
106
+ from http_client import configure_logging
107
+ from http_client.config import LoggingConfig
108
+
109
+ configure_logging(options=LoggingConfig(log_level=logging.DEBUG, json_indent=2, json_log_maxlength=4096))
110
+ ```
111
+
112
+ Environment variables **override** the arguments of that function:
113
+
114
+ | Variable | Field | Used by this library |
115
+ |---|---|---|
116
+ | `HTTP_CLIENT_LOG_LEVEL` | `log_level` | yes |
117
+ | `HTTP_CLIENT_JSON_INDENT` | `json_indent` | yes |
118
+ | `HTTP_CLIENT_JSON_LOG_MAXLENGTH` | `json_log_maxlength` | yes |
119
+ | `HTTP_CLIENT_PLAIN_TEXT_LOG_MAXLENGTH` | `plain_text_log_maxlength` | yes |
120
+ | `HTTP_CLIENT_OCTET_STREAM_LOG_MAXLENGTH` | `octet_stream_log_maxlength` | yes |
121
+ | `HTTP_CLIENT_USE_LINE_BREAKS` | `use_line_breaks` | no — text-log only setting |
122
+
123
+ Precedence for level and indent: argument of `with_structlogger` → env variable → `configure_logging`
124
+ argument → library default. `configure_logging` must be called before the client is constructed only
125
+ for values you rely on; the hooks read the singleton at call time, so later changes do take effect.
126
+
127
+ ## Event names
128
+
129
+ `http request sent` and `http response received`, exported as `event_hooks.REQUEST_EVENT` and
130
+ `event_hooks.RESPONSE_EVENT`. They are not part of the public API (`__init__` exports `with_structlogger`
131
+ only) — filter by them in log queries, but do not import them into production code.
@@ -0,0 +1,115 @@
1
+ # Pitfalls
2
+
3
+ Non-obvious behaviour that costs time when you hit it unprepared.
4
+
5
+ ## Composite fields are JSON strings, not nested values
6
+
7
+ `headers`, `query_params` and a JSON `body` reach structlog already serialized. In a console renderer
8
+ that reads fine; under `JSONRenderer` the field is a string with escaped quotes inside the outer JSON:
9
+
10
+ ```json
11
+ {"event": "http request sent", "body": "{\"user\": \"admin\"}"}
12
+ ```
13
+
14
+ This is deliberate — nested structures would collide with field names of the surrounding application
15
+ context and would explode the index of a log store. If a collector must see the body as an object, parse
16
+ that string in a structlog processor of your own, do not expect the library to hand over a `dict`.
17
+
18
+ Consequence for `json_indent`: it puts real newlines inside those strings. Useful when a human reads the
19
+ console, harmful for a line-based collector — leave it unset in production.
20
+
21
+ ## `logger` is annotated as the stdlib bound logger
22
+
23
+ The parameter type is `structlog.stdlib.BoundLogger`, but the hooks only ever call `.log(level, event, **fields)`,
24
+ which the default `structlog.get_logger()` also provides. It works at runtime; only the type checker
25
+ complains. Either configure structlog with `wrapper_class=structlog.stdlib.BoundLogger`, or accept the
26
+ mypy error at the call site.
27
+
28
+ ## structlog does its own level filtering
29
+
30
+ `log_level` is just the level handed to `.log()`. If structlog (or the stdlib logging behind it) is
31
+ configured above that level, the record is dropped and the option looks broken. Lowering `log_level` to
32
+ `DEBUG` requires lowering the structlog threshold as well.
33
+
34
+ ## Two logging options log everything twice
35
+
36
+ `with_structlogger` and `options.with_logger` append to the same hook lists and neither is aware of the
37
+ other. Both installed means two records per request in two different formats. Pick one.
38
+
39
+ ## Every attempt is a separate pair of records
40
+
41
+ A retry inside `httpx.Auth.auth_flow`, or any send decorator that calls through more than once, re-runs
42
+ the request hook — with a **new** `trace_id` each time:
43
+
44
+ ```
45
+ http request sent trace_id=ae8ad3f7…
46
+ http response received trace_id=ae8ad3f7… 401
47
+ http request sent trace_id=f0f27880…
48
+ http response received trace_id=f0f27880… 200
49
+ ```
50
+
51
+ Each request pairs with its own response, but there is no field tying the attempts together into one
52
+ logical call. Add your own correlation field (e.g. `options.with_autogenerated_request_id()`, whose
53
+ header is generated per attempt too, or a bound context variable) if you need that.
54
+
55
+ ## The request hook mutates the request
56
+
57
+ `request.extensions["trace_id"]` is written by the hook. Comparing `extensions` before and after a send
58
+ in a test will show the difference; it is not a stray mutation.
59
+
60
+ ## The async hook reads the response body
61
+
62
+ An httpx response hook fires **before** the client has read the body, and on the async client the stream
63
+ is only reachable through `await`. So `parse_response_async` awaits `aread()` for buffered responses
64
+ before building the record. The caller sees no difference — httpx would have read the same body a moment
65
+ later — and a streamed response is skipped and stays unread.
66
+
67
+ This is why the library requires `http-client` 2.1: the helpers that tell a streamed response from a
68
+ buffered one became public API in that version.
69
+
70
+ ## The hook is chosen by `isinstance(client, AsyncHttpClient)`
71
+
72
+ The option picks the sync or async response hook from the client type at construction time. A custom
73
+ async client that does not inherit from `AsyncHttpClient` gets the sync hook and fails on the first
74
+ response with `RuntimeError: Attempted to call a sync iterator on an async stream`. Inherit from
75
+ `AsyncHttpClient`.
76
+
77
+ ## Masking never touches the response the caller receives
78
+
79
+ Every masking pass builds a copy. `response.json()` and `response.content` still hold the real values —
80
+ `****` exists only in the log. Do not use the option as a way to strip secrets out of the payload.
81
+
82
+ ## `sanitize_keys` and the default list are not the same mechanism
83
+
84
+ `sanitize_keys` are exact glom paths: `user.password` masks that one field and nothing else. The default
85
+ list matches **by name at any depth**, so `password` anywhere in the body is already masked without any
86
+ configuration. Adding a default name to `sanitize_keys` is redundant, and adding a glom path expecting
87
+ name semantics ("mask `card_number` everywhere") does not work — the path must spell out the location,
88
+ `items.*.card_number`.
89
+
90
+ A path that matches nothing is skipped silently, so a typo in a path shows up as an unmasked field, not
91
+ as an error.
92
+
93
+ ## Only JSON bodies are masked
94
+
95
+ `text/plain` and `application/octet-stream` bodies land in the log as they are. A token inside a plain
96
+ text body will be logged in full.
97
+
98
+ ## A broken masking path shows up in one field only
99
+
100
+ Each part of the record is built inside its own `try`, so a glom path that raises spoils exactly one
101
+ field — `headers` becomes `<Error parse: ValueError>` while `body` stays correct. Do not read a single
102
+ `<Error parse: …>` as "logging is dead"; and note the exception message is dropped on purpose, so
103
+ diagnosing it means reproducing the path against the payload by hand.
104
+
105
+ ## An absent `body` field is not an empty body
106
+
107
+ The field is omitted when there is nothing to log at all. An unsupported content type gives
108
+ `<not logged, content-type: …>`, an unread stream gives `<streamed content>`. Three different states, so
109
+ a log query filtering on `body` will silently skip bodiless records.
110
+
111
+ ## Only `with_structlogger` is public API
112
+
113
+ `__init__` exports that one name. `event_hooks`, `parser`, `sanitizer` and the constants inside them are
114
+ internal: they moved in 1.0.0 and can move again. Filter logs by the event strings if you must, but do
115
+ not import them.
@@ -0,0 +1,3 @@
1
+ from .option import with_structlogger
2
+
3
+ __all__ = ["with_structlogger"]
@@ -0,0 +1,79 @@
1
+ import httpx
2
+ from http_client.config import LOGGING_CONFIG as CFG
3
+ from http_client.types import RequestHook, ResponseHook
4
+ from structlog.stdlib import BoundLogger
5
+
6
+ from .parser import parse_request, parse_response, parse_response_async
7
+ from .sanitizer import Sanitizer
8
+
9
+ REQUEST_EVENT = "http request sent"
10
+ RESPONSE_EVENT = "http response received"
11
+
12
+
13
+ def request_logger_hook(
14
+ logger: BoundLogger,
15
+ sanitizer: Sanitizer,
16
+ json_indent: int | None = None,
17
+ log_level: int | None = None,
18
+ ) -> RequestHook:
19
+ """Build a structured HTTP request logging hook."""
20
+
21
+ def log_request_hook(request: httpx.Request) -> None:
22
+ request_params = parse_request(
23
+ request=request,
24
+ sanitizer=sanitizer,
25
+ json_indent=_indent(json_indent),
26
+ )
27
+ logger.log(_level(log_level), REQUEST_EVENT, **request_params)
28
+
29
+ return log_request_hook
30
+
31
+
32
+ def response_logger_hook(
33
+ logger: BoundLogger,
34
+ sanitizer: Sanitizer,
35
+ json_indent: int | None = None,
36
+ log_level: int | None = None,
37
+ ) -> ResponseHook:
38
+ """Build a structured HTTP response logging hook."""
39
+
40
+ def log_response_hook(response: httpx.Response) -> None:
41
+ response_params = parse_response(
42
+ response=response,
43
+ sanitizer=sanitizer,
44
+ json_indent=_indent(json_indent),
45
+ )
46
+ logger.log(_level(log_level), RESPONSE_EVENT, **response_params)
47
+
48
+ return log_response_hook
49
+
50
+
51
+ def async_response_logger_hook(
52
+ logger: BoundLogger,
53
+ sanitizer: Sanitizer,
54
+ json_indent: int | None = None,
55
+ log_level: int | None = None,
56
+ ) -> ResponseHook:
57
+ """Build a response logging hook for an asynchronous client.
58
+
59
+ A separate implementation is needed: when the hook runs, the asynchronous
60
+ response body has not been read and its stream is only accessible through await.
61
+ """
62
+
63
+ async def log_response_hook(response: httpx.Response) -> None:
64
+ response_params = await parse_response_async(
65
+ response=response,
66
+ sanitizer=sanitizer,
67
+ json_indent=_indent(json_indent),
68
+ )
69
+ logger.log(_level(log_level), RESPONSE_EVENT, **response_params)
70
+
71
+ return log_response_hook
72
+
73
+
74
+ def _level(log_level: int | None) -> int:
75
+ return log_level if log_level is not None else CFG.log_level
76
+
77
+
78
+ def _indent(json_indent: int | None) -> int | None:
79
+ return json_indent if json_indent is not None else CFG.json_indent
@@ -0,0 +1,58 @@
1
+ from collections.abc import Sequence
2
+
3
+ from http_client import AsyncHttpClient, BaseHttpClient, ClientOption
4
+ from http_client.event_hooks import DEFAULT_SANITIZE_KEYS
5
+ from structlog.stdlib import BoundLogger
6
+
7
+ from .event_hooks import async_response_logger_hook, request_logger_hook, response_logger_hook
8
+ from .sanitizer import Sanitizer
9
+
10
+
11
+ def with_structlogger(
12
+ logger: BoundLogger,
13
+ sanitize_keys: Sequence[str] | None = None,
14
+ json_indent: int | None = None,
15
+ log_level: int | None = None,
16
+ *,
17
+ use_default_sanitize_keys: bool = True,
18
+ ) -> ClientOption:
19
+ """Set a structured logger for HTTP client requests and responses.
20
+
21
+ :param logger: Structured logger
22
+ :param sanitize_keys: Paths in [glom](https://glom.readthedocs.io/en/latest/) format
23
+ to fields to mask
24
+ :param json_indent: JSON log indentation width (no indentation by default)
25
+ :param log_level: HTTP client log level, INFO by default
26
+ :param use_default_sanitize_keys: Whether to mask fields with common secret names
27
+ (see `http_client.event_hooks.DEFAULT_SANITIZE_KEYS`)
28
+ at any depth, in addition to `sanitize_keys`
29
+ :returns: Option that sets the HTTP client logger and hooks
30
+ """
31
+ sanitizer = Sanitizer(
32
+ glom_paths=sanitize_keys or (),
33
+ secret_names=DEFAULT_SANITIZE_KEYS if use_default_sanitize_keys else (),
34
+ )
35
+
36
+ def option(client: BaseHttpClient) -> None:
37
+ client.add_request_hook(
38
+ request_logger_hook(
39
+ logger=logger,
40
+ sanitizer=sanitizer,
41
+ json_indent=json_indent,
42
+ log_level=log_level,
43
+ )
44
+ )
45
+
46
+ build_response_hook = (
47
+ async_response_logger_hook if isinstance(client, AsyncHttpClient) else response_logger_hook
48
+ )
49
+ client.add_response_hook(
50
+ build_response_hook(
51
+ logger=logger,
52
+ sanitizer=sanitizer,
53
+ json_indent=json_indent,
54
+ log_level=log_level,
55
+ )
56
+ )
57
+
58
+ return option
@@ -0,0 +1,182 @@
1
+ import json
2
+ import logging
3
+ from contextlib import suppress
4
+ from typing import cast
5
+ from uuid import uuid4
6
+
7
+ import httpx
8
+ from http_client.config import LOGGING_CONFIG as CFG
9
+ from http_client.utils.logutils import decode, is_buffered, is_streamed, parse_error, to_json, trim
10
+
11
+ from .sanitizer import Sanitizer
12
+
13
+ STREAMED_CONTENT = "<streamed content>"
14
+ UNKNOWN_TRACE_ID = "<without trace_id>"
15
+ _logger = logging.getLogger(__name__)
16
+
17
+
18
+ def parse_request(
19
+ request: httpx.Request,
20
+ sanitizer: Sanitizer,
21
+ json_indent: int | None = None,
22
+ ) -> dict[str, object]:
23
+ """Parse fields for a structured HTTP request log.
24
+
25
+ :param request: Request containing data to log
26
+ :param sanitizer: Sensitive data masking rules
27
+ :param json_indent: JSON log indentation width
28
+ :return: Structured request log fields
29
+ """
30
+ # NOTE: trace_id here only correlates the request and response.
31
+ # It is unrelated to the trace_id of a server with telemetry enabled.
32
+ trace_id = str(uuid4())
33
+ extensions = cast("dict[str, object]", request.extensions)
34
+ extensions["trace_id"] = trace_id
35
+
36
+ properties: dict[str, object] = {
37
+ "method": request.method.upper(),
38
+ "url": f"{request.url.scheme}://{request.url.host}{request.url.path}",
39
+ "headers": _parse_headers(request, sanitizer, json_indent),
40
+ "trace_id": trace_id,
41
+ }
42
+
43
+ if request.url.params:
44
+ properties["query_params"] = _parse_query_params(request, sanitizer, json_indent)
45
+
46
+ if (body := _parse_body(request, sanitizer, json_indent)) is not None:
47
+ properties["body"] = body
48
+
49
+ return properties
50
+
51
+
52
+ def parse_response(
53
+ response: httpx.Response,
54
+ sanitizer: Sanitizer,
55
+ json_indent: int | None = None,
56
+ ) -> dict[str, object]:
57
+ """Parse fields for a structured HTTP response log.
58
+
59
+ :param response: Response containing data to log
60
+ :param sanitizer: Sensitive data masking rules
61
+ :param json_indent: JSON log indentation width
62
+ :return: Structured response log fields
63
+ """
64
+ properties: dict[str, object] = {
65
+ "status_code": response.status_code,
66
+ "headers": _parse_headers(response, sanitizer, json_indent),
67
+ "trace_id": cast("dict[str, object]", response.request.extensions).get("trace_id", UNKNOWN_TRACE_ID),
68
+ }
69
+
70
+ if (body := _parse_body(response, sanitizer, json_indent)) is not None:
71
+ properties["body"] = body
72
+
73
+ return properties
74
+
75
+
76
+ async def parse_response_async(
77
+ response: httpx.Response,
78
+ sanitizer: Sanitizer,
79
+ json_indent: int | None = None,
80
+ ) -> dict[str, object]:
81
+ """Parse fields for a structured asynchronous client response log.
82
+
83
+ The httpx response hook runs before the client finishes reading the body, so
84
+ an asynchronous response still has an asynchronous stream that cannot use synchronous read().
85
+
86
+ :return: Structured response log fields
87
+ """
88
+ await _aread_buffered(response)
89
+ return parse_response(response, sanitizer, json_indent)
90
+
91
+
92
+ async def _aread_buffered(response: httpx.Response) -> None:
93
+ """Finish reading a non-streaming response body."""
94
+ if is_streamed(response):
95
+ return
96
+
97
+ # Logging must not fail the request: _parse_body handles unreadable bodies.
98
+ with suppress(Exception):
99
+ await response.aread()
100
+
101
+
102
+ def _parse_headers(
103
+ r: httpx.Request | httpx.Response,
104
+ sanitizer: Sanitizer,
105
+ json_indent: int | None,
106
+ ) -> str:
107
+ try:
108
+ return to_json(sanitizer.apply(dict[str, str](r.headers)), json_indent)
109
+ except Exception as error:
110
+ _logger.exception( # noqa: LOG007 — A traceback may expose request contents
111
+ "Не удалось подготовить поле HTTP лога: %s",
112
+ type(error).__name__,
113
+ exc_info=False,
114
+ )
115
+ return parse_error(error)
116
+
117
+
118
+ def _parse_query_params(
119
+ request: httpx.Request,
120
+ sanitizer: Sanitizer,
121
+ json_indent: int | None,
122
+ ) -> str:
123
+ try:
124
+ return to_json(sanitizer.apply(dict[str, str](request.url.params)), json_indent)
125
+ except Exception as error:
126
+ _logger.exception( # noqa: LOG007 — A traceback may expose request contents
127
+ "Не удалось подготовить поле HTTP лога: %s",
128
+ type(error).__name__,
129
+ exc_info=False,
130
+ )
131
+ return parse_error(error)
132
+
133
+
134
+ def _parse_body(
135
+ value: httpx.Request | httpx.Response,
136
+ sanitizer: Sanitizer,
137
+ json_indent: int | None = None,
138
+ ) -> str | None:
139
+ if isinstance(value, cast("type[object]", httpx.Response)):
140
+ streamed = is_streamed(cast("httpx.Response", value))
141
+ else:
142
+ streamed = not is_buffered(cast("httpx.Request", value))
143
+ if streamed:
144
+ return STREAMED_CONTENT
145
+
146
+ try:
147
+ content = value.read()
148
+ content_type = cast("str", value.headers.get("Content-Type", "")).lower()
149
+ return _render_content(content, content_type, value, sanitizer, json_indent)
150
+ except Exception as error:
151
+ _logger.exception( # noqa: LOG007 — A traceback may expose request contents
152
+ "Не удалось подготовить тело HTTP лога: %s",
153
+ type(error).__name__,
154
+ exc_info=False,
155
+ )
156
+ return parse_error(error)
157
+
158
+
159
+ def _render_content(
160
+ content: bytes,
161
+ content_type: str,
162
+ value: httpx.Request | httpx.Response,
163
+ sanitizer: Sanitizer,
164
+ json_indent: int | None,
165
+ ) -> str | None:
166
+ if not content:
167
+ return None
168
+ if content_type.startswith("application/json"):
169
+ sanitized_body = sanitizer.apply(cast("object", json.loads(content)))
170
+ return trim(to_json(sanitized_body, json_indent), CFG.json_log_maxlength)
171
+ if content_type.startswith("text/plain"):
172
+ return trim(decode(value), CFG.plain_text_log_maxlength)
173
+ if content_type.startswith("application/octet-stream"):
174
+ return _render_octet_stream(content)
175
+ return f"<not logged, content-type: {content_type}>" if content_type else None
176
+
177
+
178
+ def _render_octet_stream(content: bytes) -> str:
179
+ try:
180
+ return trim(content.decode(), CFG.octet_stream_log_maxlength)
181
+ except UnicodeDecodeError:
182
+ return f"<binary content ({len(content)} bytes)>"
@@ -0,0 +1 @@
1
+ This marker file indicates that the package supports type checking under PEP 561
@@ -0,0 +1,56 @@
1
+ from collections.abc import Iterable, Sequence
2
+ from typing import cast
3
+
4
+ from http_client.utils.sanitizer import sanitize
5
+
6
+ MASKED_VALUE = "****"
7
+
8
+
9
+ class Sanitizer:
10
+ """Rules for masking sensitive data in logs.
11
+
12
+ Exact paths and common names need different lookups. The path `user.password` selects one
13
+ specific field, while `password` from the common secrets list must match at any
14
+ depth: secrets are equally sensitive at the body root and in nested objects.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ glom_paths: Sequence[str] = (),
20
+ secret_names: Iterable[str] = (),
21
+ ) -> None:
22
+ """Create an instance with the supplied settings."""
23
+ self._glom_paths = tuple(glom_paths)
24
+ self._secret_names = frozenset(name.lower() for name in secret_names)
25
+
26
+ def apply(self, value: object) -> object:
27
+ """Return a copy of the value with sensitive data masked."""
28
+ return self._mask_by_name(self._mask_by_path(value))
29
+
30
+ def _mask_by_path(self, value: object) -> object:
31
+ if not self._glom_paths:
32
+ return value
33
+
34
+ # The branches match, but sanitize uses a TypeVar with two allowed types,
35
+ # so it cannot accept the dict | list union.
36
+ if isinstance(value, dict):
37
+ return sanitize(cast("dict[str, object]", value), self._glom_paths)
38
+ if isinstance(value, list):
39
+ return sanitize(cast("list[object]", value), self._glom_paths)
40
+
41
+ return value
42
+
43
+ def _mask_by_name(self, value: object) -> object:
44
+ if not self._secret_names:
45
+ return value
46
+
47
+ if isinstance(value, dict):
48
+ return {
49
+ key: MASKED_VALUE if str(key).lower() in self._secret_names else self._mask_by_name(item)
50
+ for key, item in cast("dict[object, object]", value).items()
51
+ }
52
+
53
+ if isinstance(value, list):
54
+ return [self._mask_by_name(item) for item in cast("list[object]", value)]
55
+
56
+ return value