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.
- cloudru_qa_http_client_structlog-1.0.0.dist-info/METADATA +168 -0
- cloudru_qa_http_client_structlog-1.0.0.dist-info/RECORD +13 -0
- cloudru_qa_http_client_structlog-1.0.0.dist-info/WHEEL +4 -0
- cloudru_qa_http_client_structlog-1.0.0.dist-info/licenses/LICENSE +202 -0
- http_client_structlog/.agents/skills/http-client-structlog-usage/SKILL.md +149 -0
- http_client_structlog/.agents/skills/http-client-structlog-usage/references/fields.md +131 -0
- http_client_structlog/.agents/skills/http-client-structlog-usage/references/pitfalls.md +115 -0
- http_client_structlog/__init__.py +3 -0
- http_client_structlog/event_hooks.py +79 -0
- http_client_structlog/option.py +58 -0
- http_client_structlog/parser.py +182 -0
- http_client_structlog/py.typed +1 -0
- http_client_structlog/sanitizer.py +56 -0
|
@@ -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
|
+
[](https://pypi.org/project/cloudru-qa-http-client-structlog/)
|
|
26
|
+
[](https://pypi.org/project/cloudru-qa-http-client-structlog/)
|
|
27
|
+
[](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
|
+

|
|
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,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,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
|