cloudru-qa-grpc-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,240 @@
1
+ Metadata-Version: 2.5
2
+ Name: cloudru-qa-grpc-client-structlog
3
+ Version: 1.0.0
4
+ Summary: Структурированные логи для gRPC-клиента
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 :: Software Development :: Testing
16
+ Classifier: Topic :: System :: Logging
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.13
19
+ Requires-Dist: cloudru-qa-grpc-client<2,>=1.0.0
20
+ Requires-Dist: structlog>=26.1.0
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Structured logs for the gRPC client
24
+
25
+ [![PyPI](https://img.shields.io/pypi/v/cloudru-qa-grpc-client-structlog)](https://pypi.org/project/cloudru-qa-grpc-client-structlog/)
26
+ [![Python](https://img.shields.io/pypi/pyversions/cloudru-qa-grpc-client-structlog)](https://pypi.org/project/cloudru-qa-grpc-client-structlog/)
27
+ [![License](https://img.shields.io/pypi/l/cloudru-qa-grpc-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 [gRPC client](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/grpc-client)
32
+ logs all requests and responses through [structlog](https://www.structlog.org/en/stable/index.html).
33
+
34
+ The standard grpc-client logger puts the request in one text string. This option logs the method, body,
35
+ metadata, and status as separate fields, visible in the console during debugging and available
36
+ 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
+ - [Log fields](#log-fields)
43
+ - [Masking secrets](#masking-secrets)
44
+ - [Parameters](#parameters)
45
+ - [Development](#development)
46
+
47
+ ## Installation
48
+
49
+ Requires Python 3.13 or later.
50
+
51
+ ```shell
52
+ pip install cloudru-qa-grpc-client-structlog
53
+ ```
54
+
55
+ Import the library as `grpc_client_structlog`. A compatible version of
56
+ [`cloudru-qa-grpc-client`](https://github.com/cloud-ru-tech/qa-libraries/tree/master/packages/grpc-client)
57
+ is installed with it.
58
+
59
+ ## Skills for AI agents
60
+
61
+ The package includes the [`grpc-client-structlog-usage`](https://github.com/cloud-ru-tech/qa-libraries/blob/master/.agents/skills/grpc-client-structlog-usage/SKILL.md) skill
62
+ with examples and usage rules for AI agents. Install the library
63
+ in your project environment, then run this from its root (requires [`uv`](https://docs.astral.sh/uv/)):
64
+
65
+ ```shell
66
+ uvx library-skills install --skill grpc-client-structlog-usage --yes
67
+ ```
68
+
69
+ For Claude Code, add `--claude`. The instructions are shipped with the library version.
70
+
71
+ ## Usage
72
+
73
+ Pass the option to the client constructor; logging is then automatic:
74
+
75
+ ```python
76
+ import structlog
77
+
78
+ from grpc_client import GRPCClient
79
+ from grpc_client_structlog import with_structlogger
80
+
81
+ from api import orders_pb2, orders_pb2_grpc
82
+
83
+
84
+ class OrdersClient(GRPCClient[orders_pb2_grpc.OrdersStub]):
85
+ @property
86
+ def stub_class(self):
87
+ return orders_pb2_grpc.OrdersStub
88
+
89
+ def get_order(self, order_id: str):
90
+ return self.send_unary_unary_request(orders_pb2.GetOrderRequest(id=order_id), self.stub.GetOrder)
91
+
92
+
93
+ with OrdersClient.from_addr(
94
+ "orders.example.com:443",
95
+ with_structlogger(structlog.get_logger("orders")),
96
+ ) as client:
97
+ client.get_order("42")
98
+ ```
99
+
100
+ The request is logged as a `grpc request sent` event, the response as `grpc response received`.
101
+ A shared `trace_id` field links them.
102
+
103
+ ## Asynchronous client
104
+
105
+ Use the same option without extra configuration: the interceptor is selected
106
+ based on the client type:
107
+
108
+ ```python
109
+ from grpc_client import AsyncGRPCClient
110
+
111
+
112
+ class AsyncOrdersClient(AsyncGRPCClient[orders_pb2_grpc.OrdersStub]):
113
+ @property
114
+ def stub_class(self):
115
+ return orders_pb2_grpc.OrdersStub
116
+
117
+ async def get_order(self, order_id: str):
118
+ return await self.send_unary_unary_request(orders_pb2.GetOrderRequest(id=order_id), self.stub.GetOrder)
119
+
120
+
121
+ async with AsyncOrdersClient.from_addr("orders.example.com:443", with_structlogger(logger)) as client:
122
+ await client.get_order("42")
123
+ ```
124
+
125
+ The final response-stream record is written in a separate task, so close the asynchronous
126
+ client: `close()` waits for these tasks.
127
+
128
+ ## Log fields
129
+
130
+ Up to two records per call, linked by a shared `trace_id`: request and response.
131
+
132
+ | Field | Event | Value |
133
+ |---|---|---|
134
+ | `method` | both | full method name such as `/package.Service/Method` |
135
+ | `body` | both | message body as a JSON string; responses include it only for successful unary calls |
136
+ | `metadata` | both | metadata as a JSON string; omitted if no metadata was present |
137
+ | `status_code` | response | status code name: `OK`, `NOT_FOUND`, … |
138
+ | `details` | response | service error text; present only for failed calls |
139
+ | `messages_count` | response | number of messages received from the stream; present only for streaming calls |
140
+ | `trace_id` | both | UUID4 generated per request |
141
+
142
+ `status_code` contains the code **name**, not its number. Names are readable without a reference
143
+ and easy to filter. Including the number would give the field two values and require
144
+ substring searches for `5 (NOT_FOUND)`.
145
+
146
+ Compound values, `body` and `metadata`, are serialized as JSON strings before logging,
147
+ rather than nested structures, to avoid mixing their fields with application context fields.
148
+
149
+ Neither request nor response stream bodies are logged: consuming a stream for logging would change
150
+ call behavior. A streaming request uses `<streamed content>` in `body`, while a response stream
151
+ logs its final status and `messages_count`.
152
+
153
+ Long bodies are truncated at 4096 characters by default, followed by
154
+ `... <обрезано>` in `body`. Set the limit with `payload_maxlength`. If preparing the body fails,
155
+ for example because of an invalid glom path in `sanitize_keys`, `body` contains
156
+ `<не удалось подготовить тело сообщения>`. The request still goes through: logging is designed
157
+ to avoid breaking calls.
158
+
159
+ ## Masking secrets
160
+
161
+ Fields with common secret names are masked **by default at every depth**, including
162
+ nested messages and array items:
163
+
164
+ `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `api-key`, `api_key`,
165
+ `access_token`, `refresh_token`, `password`, `secret`, `token`.
166
+
167
+ Names match across naming styles, so `accessToken` in a message body is also hidden.
168
+
169
+ Add your own fields with `sanitize_keys`. A key without separators is treated as a field name
170
+ and masked at every depth; a key with a dot, star, or brackets is treated as a path
171
+ [glom](https://glom.readthedocs.io/en/latest/):
172
+
173
+ ```python
174
+ with_structlogger(
175
+ logger,
176
+ sanitize_keys=["x-tenant-key", "user.internal_id", "items.*.card_number"],
177
+ )
178
+ ```
179
+
180
+ Disable the default list explicitly with `use_default_sanitize_keys=False`.
181
+
182
+ Masking leaves the original message unchanged: `****` appears only in the log.
183
+
184
+ The `details` field, containing service error text, is neither masked nor truncated: it is free text,
185
+ while masking works with field names and paths. If the service puts confidential information
186
+ in the status, it appears in the log unchanged. The standard `options.with_logger` behaves the same way.
187
+
188
+ ## Parameters
189
+
190
+ | Parameter | Type | Purpose |
191
+ |---|---|---|
192
+ | `logger` | `BoundLogger \| FilteringBoundLogger` | where to write |
193
+ | `sanitize_keys` | `Sequence[str] \| None` | field names and glom paths whose values are replaced with `****` |
194
+ | `json_indent` | `int \| None` | indentation width for JSON properties |
195
+ | `log_level` | `int \| None` | level for successful records; `INFO` by default |
196
+ | `err_log_level` | `int \| None` | level for failed calls; `ERROR` by default |
197
+ | `payload_maxlength` | `int \| None` | maximum logged body length; 4096 characters by default |
198
+ | `use_default_sanitize_keys` | `bool` | mask common secrets at every depth; `True` by default |
199
+
200
+ Set levels, indentation, and body length limits for the whole project with
201
+ `grpc_client.configure_logging(...)`. Values passed to `with_structlogger` override shared settings.
202
+
203
+ `GRPC_CLIENT_*` environment variables have no effect on their own: `configure_logging(...)` reads them
204
+ when called. Without that call, the library defaults remain in effect.
205
+
206
+ `log_level` and `err_log_level` accept only `DEBUG`, `INFO`, `WARNING`, `ERROR`, and `CRITICAL`:
207
+ structlog's bound logger selects a method by level name, and only these five names are available.
208
+ An intermediate value such as `15` raises `ValueError` immediately, rather than on the first request.
209
+
210
+ structlog filters levels itself: if the logger threshold is above `log_level`, the record does not
211
+ reach the output and the option may appear broken.
212
+
213
+ ## Development
214
+
215
+ The library is developed in the [qa-libraries](https://github.com/cloud-ru-tech/qa-libraries) monorepo
216
+ alongside the other `cloudru-qa-*` libraries. You need [`uv`](https://docs.astral.sh/uv/), GNU Make,
217
+ and [`checkmake`](https://github.com/checkmake/checkmake).
218
+ Run commands from the repository root:
219
+
220
+ ```shell
221
+ make sync # install dependencies for all libraries from uv.lock
222
+ make test-grpc-client-structlog # tests for this library
223
+ make typecheck-grpc-client-structlog # mypy for this library
224
+ make check # formatting, ruff, mypy, and tests for the entire workspace
225
+ ```
226
+
227
+ Tests require Docker running: the gRPC service starts in a container from `tests/docker-compose.yml`,
228
+ and its image is built on the first run. If the service is already running, skip starting the container
229
+ by passing its address:
230
+
231
+ ```shell
232
+ uv run --no-sync python packages/grpc-client-structlog/tests/service.py &
233
+ SERVICE_ADDR=localhost:50051 make test-grpc-client-structlog
234
+ ```
235
+
236
+ Tests run on Python 3.13 and 3.14. Create a separate environment for another version:
237
+ `make sync test-grpc-client-structlog PYTHON_VERSION=3.14`.
238
+
239
+ Publish a release with the git tag `grpc-client-structlog/v<version>`. The tag version must match
240
+ the version in `packages/grpc-client-structlog/pyproject.toml`.
@@ -0,0 +1,12 @@
1
+ grpc_client_structlog/__init__.py,sha256=JFrlU8OlVVPWgB-8J_Zv4m7oTWzWMBlv7q3GHBqpusU,71
2
+ grpc_client_structlog/option.py,sha256=p_KehH3o0YV3o1VvhXIzY54iVMXo5CfcEgiAEFVbIio,3084
3
+ grpc_client_structlog/parser.py,sha256=OH62a-fTJfcu1IVk9ydhxdkXe1SXS9KJxbcmvML1rcs,6201
4
+ grpc_client_structlog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ grpc_client_structlog/sink.py,sha256=nuIUyI7z8FoLbqH1P_NaUH55LOO_WFt3PaKjihTEI7M,4251
6
+ grpc_client_structlog/.agents/skills/grpc-client-structlog-usage/SKILL.md,sha256=z2QNgfshblmWVg_36HxS6p0lpDwb7xcMTmAhkPVtssA,9250
7
+ grpc_client_structlog/.agents/skills/grpc-client-structlog-usage/references/fields.md,sha256=D5WeyqbIJ9UErXly-2dEW73EH_YQXVTepOWUgecEwog,7466
8
+ grpc_client_structlog/.agents/skills/grpc-client-structlog-usage/references/pitfalls.md,sha256=NRHOIKOB88Dv-Yn1VinP1TN0YqgBIO8jS-Jh1aNz_rM,9804
9
+ cloudru_qa_grpc_client_structlog-1.0.0.dist-info/METADATA,sha256=pOgz1IrDDr8gtp7eWzA7JZeH-qnaJV6o3eXW1YKuZ24,10416
10
+ cloudru_qa_grpc_client_structlog-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
11
+ cloudru_qa_grpc_client_structlog-1.0.0.dist-info/licenses/LICENSE,sha256=z8d0m5b2O9McPEK1xHG_dWgUBT6EfBDz6wA0F7xSPTA,11358
12
+ cloudru_qa_grpc_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,191 @@
1
+ ---
2
+ name: grpc-client-structlog-usage
3
+ description: Structured request/response logging for the grpc_client library — the with_structlogger option that writes every gRPC call into structlog as separate fields instead of one text line, with secret masking on by default. Use when a project imports grpc_client_structlog, when wiring structlog into a gRPC client, or when the built-in options.with_logger crashes with "object has no attribute 'isEnabledFor'". Key names are with_structlogger, sanitize_keys, use_default_sanitize_keys, status_code, messages_count, trace_id.
4
+ license: Apache-2.0
5
+ metadata:
6
+ library-version: "1.0.0"
7
+ source: packages/grpc-client-structlog
8
+ ---
9
+
10
+ # Structured logging for grpc_client
11
+
12
+ The library exports exactly one name: `with_structlogger`. It is a `ClientOption` that replaces
13
+ `grpc_client.options.with_logger`. Instead of one text line per call, every attribute — method, body,
14
+ metadata, 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, stubs, timeouts, auth, streaming — is plain `grpc_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 add their own
23
+ interceptor; passing both logs every call twice, in two different formats.
24
+ 2. Pass it as a constructor option, exactly like the built-in ones. There is no post-construction setup.
25
+ 3. Put it **after** `options.with_auth(...)` so the log shows the request that actually goes out.
26
+ 4. The library plugs into `options.with_log_sink` and the record types of `grpc_client`; the matching
27
+ `cloudru-qa-grpc-client` is installed as a dependency.
28
+ 5. Do not name a structlog field `message` anywhere in the pipeline: this library calls the payload
29
+ `body` on purpose, because `EventRenamer("message")` would collide with it.
30
+ 6. Do not turn off `use_default_sanitize_keys` to "clean up" the log. It unmasks passwords and tokens.
31
+ 7. Levels are limited to `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL`. Anything else raises
32
+ `ValueError` when the client is built — structlog cannot write an unnamed level.
33
+
34
+ Before debugging odd output, read `references/pitfalls.md` — it covers the non-obvious behaviour.
35
+
36
+ ## Why it exists
37
+
38
+ `options.with_logger` takes a stdlib logger and writes one text line per call. Feeding it a structlog
39
+ bound logger does not work: it checks the level with `logger.isEnabledFor(level)`, and a logger built
40
+ by `structlog.make_filtering_bound_logger(...)` spells that method `is_enabled_for`, so every call dies
41
+ with:
42
+
43
+ ```
44
+ AttributeError: 'BoundLoggerFilteringAtDebug' object has no attribute 'isEnabledFor'.
45
+ Did you mean: 'is_enabled_for'?
46
+ ```
47
+
48
+ `with_structlogger` installs a `LogSink` instead — the public extension point of `grpc-client` 2.1.
49
+ The client builds a `RequestRecord` / `ResponseRecord` and picks the level; the sink turns it into
50
+ structlog fields. The level question goes through `is_enabled_for`, and a logger that cannot answer
51
+ it — the method is missing, or it raises, as `structlog.stdlib.BoundLogger` over a
52
+ `structlog.PrintLogger` does — counts as enabled. A question about the level must never take the
53
+ call down.
54
+
55
+ ## Usage
56
+
57
+ ```python
58
+ import structlog
59
+ from api import orders_pb2, orders_pb2_grpc
60
+ from grpc_client import GRPCClient
61
+ from grpc_client_structlog import with_structlogger
62
+
63
+
64
+ class OrdersClient(GRPCClient[orders_pb2_grpc.OrdersStub]):
65
+ @property
66
+ def stub_class(self) -> type[orders_pb2_grpc.OrdersStub]:
67
+ return orders_pb2_grpc.OrdersStub
68
+
69
+ def get_order(self, order_id: str):
70
+ return self.send_unary_unary_request(orders_pb2.GetOrderRequest(id=order_id), self.stub.GetOrder)
71
+
72
+
73
+ with OrdersClient.from_addr(
74
+ "orders.example.com:443",
75
+ with_structlogger(structlog.get_logger("orders")),
76
+ ) as client:
77
+ client.get_order("42")
78
+ ```
79
+
80
+ Combines with any other option:
81
+
82
+ ```python
83
+ from grpc_client import options
84
+
85
+ client = OrdersClient.from_addr(
86
+ "orders.example.com:443",
87
+ options.with_auth(auth),
88
+ options.with_timeout(30),
89
+ with_structlogger(logger, json_indent=2),
90
+ )
91
+ ```
92
+
93
+ ## Async client
94
+
95
+ Same option, same arguments. The sync or async interceptor is picked from the client type inside the
96
+ option, so nothing extra is configured:
97
+
98
+ ```python
99
+ from grpc_client import AsyncGRPCClient
100
+
101
+
102
+ class AsyncOrdersClient(AsyncGRPCClient[orders_pb2_grpc.OrdersStub]):
103
+ @property
104
+ def stub_class(self) -> type[orders_pb2_grpc.OrdersStub]:
105
+ return orders_pb2_grpc.OrdersStub
106
+
107
+ async def get_order(self, order_id: str):
108
+ return await self.send_unary_unary_request(orders_pb2.GetOrderRequest(id=order_id), self.stub.GetOrder)
109
+
110
+
111
+ async with AsyncOrdersClient.from_addr("orders.example.com:443", with_structlogger(logger)) as client:
112
+ await client.get_order("42")
113
+ ```
114
+
115
+ The final record of a response stream is written by a separate task, so an async client must be
116
+ closed — `close()` waits for those tasks. `async with` does it for you.
117
+
118
+ ## What lands in the log
119
+
120
+ Up to two events per call, linked by `trace_id` — a level filtered out by structlog simply produces no
121
+ record, so a call can end up with one or none:
122
+
123
+ | Field | Event | Value |
124
+ |---|---|---|
125
+ | `event` | both | `grpc request sent` / `grpc response received` |
126
+ | `method` | both | `/package.Service/Method` |
127
+ | `body` | request always, response only on a successful unary answer | JSON string of the message; `<streamed content>` for a request stream |
128
+ | `metadata` | both | JSON string; **absent** when the call carried no metadata |
129
+ | `status_code` | response | status **name**: `OK`, `NOT_FOUND`, … |
130
+ | `details` | response | the service's error text; present only on a failed call |
131
+ | `messages_count` | response | number of messages read from a response stream |
132
+ | `trace_id` | both | UUID4 generated per call |
133
+
134
+ `status_code` holds the name, not the number: the name reads without a lookup table and filters
135
+ cleanly, while `5 (NOT_FOUND)` would force substring queries. `messages_count` is the only integer
136
+ field; other composite values are serialized to JSON strings. The full catalog is in
137
+ `references/fields.md`.
138
+
139
+ ## Masking
140
+
141
+ Fields whose **name** is a common secret — `authorization`, `cookie`, `token`, `password`, `api_key`
142
+ and the rest of `grpc_client.utils.sanitizer.DEFAULT_SANITIZE_KEYS` — are replaced with `****` by
143
+ default, **at any depth**, and regardless of naming style (`accessToken` matches `access_token`).
144
+
145
+ Project-specific fields go into `sanitize_keys`. A key without separators is a **field name** masked
146
+ at any depth; a key with a dot, a star or brackets is a [glom](https://glom.readthedocs.io/en/latest/)
147
+ **path** applied as written:
148
+
149
+ ```python
150
+ with_structlogger(logger, sanitize_keys=["x-tenant-key", "user.internal_id", "items.*.card_number"])
151
+ ```
152
+
153
+ | Key | What it masks |
154
+ |---|---|
155
+ | `x-tenant-key` | a metadata key (metadata is flat, whole-key match, case-insensitive) |
156
+ | `client_secret` | a body field with that name, at any depth |
157
+ | `user.internal_id` | one nested body field |
158
+ | `items.*.card_number` | a field in every array element |
159
+
160
+ Masking never touches the message the caller receives — only the copy that goes into the log.
161
+
162
+ ## Parameters
163
+
164
+ | Parameter | Type | Meaning |
165
+ |---|---|---|
166
+ | `logger` | `BoundLogger \| FilteringBoundLogger` | where to write |
167
+ | `sanitize_keys` | `Sequence[str] \| None` | field names and glom paths replaced with `****` |
168
+ | `json_indent` | `int \| None` | indent used when serializing JSON fields |
169
+ | `log_level` | `int \| None` | level of a successful record, defaults to `INFO` |
170
+ | `err_log_level` | `int \| None` | level of a failed record, defaults to `ERROR` |
171
+ | `payload_maxlength` | `int \| None` | length limit for `body`, defaults to 4096 characters |
172
+ | `use_default_sanitize_keys` | `bool` | mask common secret names at any depth, default `True` |
173
+
174
+ Levels, indent and the body limit fall back to the global `grpc_client` settings when omitted — set
175
+ them once for the whole project with `grpc_client.configure_logging(...)`. An argument passed to
176
+ `with_structlogger` wins over the global value. The `GRPC_CLIENT_*` environment variables do nothing
177
+ on their own: `configure_logging(...)` reads them when it is called, so a project that never calls it
178
+ gets library defaults regardless of the environment.
179
+
180
+ `log_level` and `err_log_level` accept **only** `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL`. A
181
+ structlog bound logger picks its method by level name, and only those five have one. Anything else —
182
+ including a value coming from `configure_logging(...)` — raises `ValueError` while the client is being
183
+ built. The built-in `options.with_logger` accepts arbitrary integers; this is a deliberate divergence,
184
+ because the alternative is a `KeyError` from inside structlog that also stops the request from going
185
+ out at all.
186
+
187
+ ## References
188
+
189
+ - `references/fields.md` — the field catalog, streaming behaviour, global defaults and env variables.
190
+ - `references/pitfalls.md` — JSON strings vs nested values, the `message` field, level filtering,
191
+ typing of `logger`, and how the option plugs into `grpc-client`.
@@ -0,0 +1,161 @@
1
+ # Field catalog and configuration
2
+
3
+ Two records per gRPC call. Field sets differ; `method`, `metadata` and `trace_id` are shared.
4
+
5
+ ## Request — `grpc request sent`
6
+
7
+ | Field | Type | Value |
8
+ |---|---|---|
9
+ | `method` | `str` | full method name, `/package.Service/Method` |
10
+ | `body` | `str` | JSON object of the request message after masking |
11
+ | `metadata` | `str` | JSON object of the request metadata; **absent** when there was none |
12
+ | `trace_id` | `str` | UUID4 generated for this call |
13
+
14
+ The record is written **before** the call goes out, so it exists even when the call never reaches the
15
+ service.
16
+
17
+ ## Response — `grpc response received`
18
+
19
+ The same event name covers a successful unary answer, a failed call, and the end of a response
20
+ stream. Which fields appear tells them apart:
21
+
22
+ | Field | Type | Present when |
23
+ |---|---|---|
24
+ | `method` | `str` | always |
25
+ | `status_code` | `str` | always — the status **name**, e.g. `OK`, `DEADLINE_EXCEEDED` |
26
+ | `body` | `str` | successful unary answer only |
27
+ | `details` | `str` | failed call only; may be an empty string if the service said nothing |
28
+ | `messages_count` | `int` | stream calls only, including a failed stream |
29
+ | `metadata` | `str` | trailing metadata was non-empty |
30
+ | `trace_id` | `str` | always — matches the request record |
31
+
32
+ `messages_count` is the only integer field. Everything composite (`body`, `metadata`) is serialized
33
+ into a JSON string, not passed as a nested dict.
34
+
35
+ A successful record is written at `log_level` (default `INFO`), a failed one at `err_log_level`
36
+ (default `ERROR`). "Failed" means any status other than `OK`, including `CANCELLED` after the caller
37
+ cancelled the stream itself.
38
+
39
+ ## Streams
40
+
41
+ The body of a stream never lands in the log — reading it for the sake of logging would change the
42
+ behaviour of the call.
43
+
44
+ | Call type | Request `body` | Response fields |
45
+ |---|---|---|
46
+ | unary-unary | the message | `body` |
47
+ | unary-stream | the message | `messages_count` |
48
+ | stream-unary | `<streamed content>` | `body` |
49
+ | stream-stream | `<streamed content>` | `messages_count` |
50
+
51
+ `messages_count` counts messages actually read by the caller. Two consequences:
52
+
53
+ - a stream that is abandoned half-way logs the count read up to that point, with the status the call
54
+ ended on (usually `CANCELLED` or `DEADLINE_EXCEEDED`);
55
+ - on the async client, messages taken with `call.read()` instead of async iteration bypass the
56
+ counter — `grpc.aio` reads them past the interceptor's wrapper. `AsyncGRPCStreamResponse` iterates,
57
+ so `collect()` and `async for` count correctly.
58
+
59
+ The final stream record is written when the call finishes, not when the last message is read. On the
60
+ async client that happens in a separate task, which `AsyncGRPCClient.close()` waits for.
61
+
62
+ ## Body rendering
63
+
64
+ The message is converted with `MessageToDict(always_print_fields_with_no_presence=True)`, masked, and
65
+ serialized with `ensure_ascii=False`. So field names appear in protobuf JSON style (`accessToken`, not
66
+ `access_token`), and unset scalar fields appear with their default values.
67
+
68
+ A protobuf `bytes` field is what `MessageToDict` makes of it — **Base64**, not text: `b"text"` is
69
+ logged as `"dGV4dA=="`. The `<двоичные данные>` marker never appears in a body; it belongs to binary
70
+ metadata values (`-bin` keys), which reach the JSON encoder as raw `bytes` and are decoded as UTF-8
71
+ when they can be.
72
+
73
+ Two failure modes replace the body:
74
+
75
+ | `body` holds | Meaning |
76
+ |---|---|
77
+ | `<не удалось подготовить тело сообщения>` | conversion, masking or serialization raised |
78
+ | `... <обрезано>` at the end | the JSON exceeded `payload_maxlength` (4096 characters by default) |
79
+
80
+ Both constants live in `grpc_client.utils.logutils` (`PAYLOAD_UNAVAILABLE`, `TRUNCATION_SUFFIX`).
81
+ A broken glom path in `sanitize_keys` is the usual cause of the first one — the request itself still
82
+ goes out, because the log is built defensively.
83
+
84
+ ## Metadata rendering
85
+
86
+ Metadata is masked, then grouped into a JSON object. A repeated key keeps all of its values as a
87
+ list:
88
+
89
+ ```json
90
+ {"x-region": ["ural", "siberia"], "authorization": "****"}
91
+ ```
92
+
93
+ A dict would have kept only the last value, and the log would no longer match what was sent.
94
+ The field is omitted entirely when there is no metadata — an empty JSON object is never written.
95
+
96
+ ## Masking mechanics
97
+
98
+ One pass over the payload, driven by the merged key list (`sanitize_keys` plus, unless disabled,
99
+ `DEFAULT_SANITIZE_KEYS`):
100
+
101
+ | Key shape | Applies to | Matches |
102
+ |---|---|---|
103
+ | no separators (`password`) | body | a field with that name at **any** depth |
104
+ | dot / star / brackets (`user.token`) | body | that glom path only |
105
+ | any | metadata | the whole key, case-insensitive — metadata is flat, paths are not parsed |
106
+
107
+ Name matching normalizes to snake_case, so `accessToken`, `AccessToken` and `access_token` all match
108
+ `access_token`, and a composite name ending in a secret (`client_secret`, `user_password`) matches
109
+ too. Substring matching is deliberately not used — it would mask a harmless `tokenizer`.
110
+
111
+ Default names: `authorization`, `proxy-authorization`, `cookie`, `set-cookie`, `x-api-key`, `api-key`,
112
+ `api_key`, `access_token`, `refresh_token`, `password`, `secret`, `token`.
113
+
114
+ Name-based masking replaces the whole value, so a nested message under a field named `token`
115
+ disappears entirely rather than being walked into. Traversal stops at 64 levels and replaces what is
116
+ left with `<превышена глубина вложенности>` — a recursive structure must not take the call down with it.
117
+
118
+ ## Global defaults
119
+
120
+ `log_level`, `err_log_level`, `json_indent` and the payload length limit fall back to
121
+ `grpc_client.config.LOGGING_CONFIG` whenever they are not passed to `with_structlogger`. Fill that
122
+ singleton once at startup:
123
+
124
+ ```python
125
+ import logging
126
+
127
+ from grpc_client import configure_logging
128
+
129
+ configure_logging(
130
+ log_level=logging.DEBUG,
131
+ err_log_level=logging.WARNING,
132
+ json_indent=2,
133
+ payload_log_maxlength=4096,
134
+ )
135
+ ```
136
+
137
+ Only `DEBUG`, `INFO`, `WARNING`, `ERROR` and `CRITICAL` are accepted for the two levels, here and in
138
+ `configure_logging(...)` alike — a level structlog has no name for raises `ValueError` when the client
139
+ is built.
140
+
141
+ Environment variables **override** the arguments of that function — but only while it runs. Nothing
142
+ reads them on import, so a project that never calls `configure_logging(...)` gets library defaults no
143
+ matter what the environment says:
144
+
145
+ | Variable | Field | Used by this library |
146
+ |---|---|---|
147
+ | `GRPC_CLIENT_LOG_LEVEL` | `log_level` | yes |
148
+ | `GRPC_CLIENT_ERR_LOG_LEVEL` | `err_log_level` | yes |
149
+ | `GRPC_CLIENT_JSON_INDENT` | `json_indent` | yes |
150
+ | `GRPC_CLIENT_PAYLOAD_LOG_MAXLENGTH` | `payload_log_maxlength` | yes — argument `payload_maxlength` |
151
+ | `GRPC_CLIENT_USE_LINE_BREAKS` | `use_line_breaks` | no — text-log only setting |
152
+
153
+ Precedence: argument of `with_structlogger` → env variable → `configure_logging` argument → library
154
+ default (the middle two only apply once `configure_logging(...)` has run). `use_line_breaks` is the only setting with no counterpart here — this library never joins
155
+ fields into one line.
156
+
157
+ ## Event names
158
+
159
+ `grpc request sent` and `grpc response received`, exported as `sink.REQUEST_EVENT` and
160
+ `sink.RESPONSE_EVENT`. They are not part of the public API (`__init__` exports
161
+ `with_structlogger` only) — filter by them in log queries, but do not import them into production code.
@@ -0,0 +1,176 @@
1
+ # Pitfalls
2
+
3
+ Non-obvious behaviour that costs time when you hit it unprepared.
4
+
5
+ ## The built-in option crashes on a filtering bound logger
6
+
7
+ This is the whole reason the library exists. `grpc_client.options.with_logger` calls
8
+ `logger.isEnabledFor(level)`. A logger from `structlog.configure(wrapper_class=structlog.make_filtering_bound_logger(...))`
9
+ has `is_enabled_for` instead, so every call dies with:
10
+
11
+ ```
12
+ AttributeError: 'BoundLoggerFilteringAtDebug' object has no attribute 'isEnabledFor'
13
+ ```
14
+
15
+ `with_structlogger` asks through `is_enabled_for` and falls back to "enabled" when the logger cannot
16
+ answer. "Cannot answer" covers two cases: the method is absent, and the method **raises**. The second
17
+ one is real — `structlog.stdlib.BoundLogger` declares both spellings but forwards them to the logger
18
+ underneath, so
19
+ `structlog.configure(wrapper_class=structlog.stdlib.BoundLogger, logger_factory=structlog.PrintLoggerFactory())`
20
+ gives a logger that writes fine and explodes on the level question. Under the built-in option that
21
+ combination kills every call.
22
+
23
+ The check itself is not optional: building the body means `MessageToDict` plus masking plus
24
+ `json.dumps`, and doing that for a record nobody will keep is wasteful.
25
+
26
+ What is **not** guarded is the write itself. If the structlog processor chain raises, the exception
27
+ comes out of the call — unlike stdlib `logging`, structlog does not swallow errors of its own
28
+ pipeline, and hiding them would mean losing every record silently.
29
+
30
+ ## The payload field is `body`, never `message`
31
+
32
+ Many pipelines end with `structlog.processors.EventRenamer("message")`, which moves `event` into
33
+ `message`. A field of the library's own named `message` would be overwritten by the event text and
34
+ disappear. So the payload is `body`, matching `http_client_structlog`. Do not rename it back.
35
+
36
+ ## Composite fields are JSON strings, not nested values
37
+
38
+ `body` and `metadata` reach structlog already serialized. In a console renderer that reads fine; under
39
+ `JSONRenderer` the field is a string with escaped quotes inside the outer JSON:
40
+
41
+ ```json
42
+ {"event": "grpc request sent", "body": "{\"login\": \"morgan\"}"}
43
+ ```
44
+
45
+ This is deliberate — nested structures would collide with field names of the surrounding application
46
+ context and would explode the index of a log store. If a collector must see the body as an object,
47
+ parse that string in a structlog processor of your own.
48
+
49
+ Consequence for `json_indent`: it puts real newlines inside those strings. Useful when a human reads
50
+ the console, harmful for a line-based collector — leave it unset in production.
51
+
52
+ ## structlog does its own level filtering
53
+
54
+ `log_level` is just the level handed to `.log()`. If structlog (or the stdlib logging behind it) is
55
+ configured above that level, the record is dropped and the option looks broken. Lowering `log_level`
56
+ to `DEBUG` requires lowering the structlog threshold as well.
57
+
58
+ When **both** `log_level` and `err_log_level` are filtered out, the interceptor skips everything —
59
+ it does not even generate a `trace_id` or wrap the stream for counting.
60
+
61
+ ## Only five log levels exist
62
+
63
+ `logging` lets you invent levels — `logger.log(15, ...)` is fine, and `options.with_logger` passes it
64
+ through. structlog does not: a bound logger maps the level to a method **name**, and only
65
+ `DEBUG` / `INFO` / `WARNING` / `ERROR` / `CRITICAL` have one. Both `structlog.stdlib.BoundLogger` and
66
+ the classes from `make_filtering_bound_logger` raise `KeyError: 15`.
67
+
68
+ So `with_structlogger` validates the two levels while the client is built and raises `ValueError`
69
+ instead. The check runs on the **resolved** values, so a bad level in
70
+ `grpc_client.configure_logging(...)` or in `GRPC_CLIENT_LOG_LEVEL` is caught too. Without it the
71
+ failure would land on the first RPC, during the request record — before the call goes out, so the
72
+ request would never be sent.
73
+
74
+ ## `details` is the one field that is never masked
75
+
76
+ `body` and `metadata` go through the sanitizer. `details` — the service's error text — does not, and
77
+ `payload_maxlength` does not trim it either. It is a free-form string, and masking matches field names
78
+ and glom paths, so there is nothing to match on. `sanitize_keys=["details"]` does **not** help: that
79
+ list applies inside the payload, not to the record's own fields.
80
+
81
+ A service that does `context.abort(INVALID_ARGUMENT, f"bad token {token}")` leaks it. The built-in
82
+ `options.with_logger` behaves identically, so this is not a regression — but it is the one channel
83
+ where a secret reaches the log untouched.
84
+
85
+ ## The final record of an unread sync stream comes from a grpcio thread
86
+
87
+ `_CountingCall` registers the finish log through grpcio's `add_done_callback`. When the caller is not
88
+ sitting inside `next()` — the stream was abandoned, hit its deadline, or the channel closed — grpcio
89
+ runs that callback on one of its **own** threads, and the record is written there. So the processor
90
+ chain and the sink get called from a thread the application never set up: they have to be thread-safe.
91
+
92
+ `structlog.contextvars` do survive that hop — verified on grpcio 1.83 for an abandoned stream, an
93
+ expired deadline, and a context bound in a worker thread: the finish record carried the same
94
+ `request_id` as the request record, even though a plain `threading.Thread` sees no contextvars at all.
95
+ Do not rely on it blindly across grpcio upgrades, but it is not a known hole today.
96
+
97
+ ## Two logging options log everything twice
98
+
99
+ `with_structlogger` and `options.with_logger` each append their own interceptor and neither is aware
100
+ of the other. Both installed means two records per call in two different formats. Pick one.
101
+
102
+ ## Order matters relative to auth
103
+
104
+ Interceptors run in the order they are added. Put `with_structlogger` **after**
105
+ `options.with_auth(...)`, otherwise the logged metadata is the request before the token was injected.
106
+
107
+ ## `logger` accepts both bound logger flavours
108
+
109
+ The parameter type is `structlog.stdlib.BoundLogger | structlog.typing.FilteringBoundLogger`. The two
110
+ are not interchangeable for a type checker — the stdlib one spells the level check `getEffectiveLevel`
111
+ and the filtering protocol spells it `get_effective_level` — so neither alone would accept the other,
112
+ hence the union. At runtime the option only calls `.log(level, event, **fields)` and
113
+ `is_enabled_for(level)`, which any bound logger provides; a logger built by some other wrapper class
114
+ still works, it just needs a `cast` to type-check.
115
+
116
+ ## The counter and the async client
117
+
118
+ `messages_count` counts what the caller actually read. On the async client, `call.read()` goes past
119
+ the interceptor's wrapper, so those messages are not counted; async iteration (`async for`,
120
+ `AsyncGRPCStreamResponse.collect()`) is counted correctly.
121
+
122
+ ## The async stream record arrives after the call, not after the last message
123
+
124
+ It is written by a task scheduled from the call's completion callback. Assert on it only after
125
+ `await client.close()` (or after leaving `async with`), which drains those tasks. A test that checks
126
+ the stream record right after `collect()` is racy.
127
+
128
+ ## Masking never touches the message the caller receives
129
+
130
+ Every masking pass builds a copy. The response message still holds the real values — `****` exists
131
+ only in the log. Do not use the option as a way to strip secrets out of a payload.
132
+
133
+ ## `sanitize_keys` mixes two mechanisms
134
+
135
+ A key without separators is a **name** matched at any depth; a key with a dot, star or brackets is an
136
+ exact **glom path**. `password` masks the field everywhere, `user.password` masks exactly one place.
137
+ For metadata neither applies literally: metadata is flat, so the whole key is compared,
138
+ case-insensitively, and a glom-looking key is matched as a plain key name.
139
+
140
+ A path that matches nothing is skipped silently, so a typo shows up as an unmasked field, not as an
141
+ error.
142
+
143
+ ## A broken glom path costs the whole body
144
+
145
+ Unlike `http_client_structlog`, where each field is protected separately, the gRPC body is one unit:
146
+ if masking or serialization raises, `body` becomes `<не удалось подготовить тело сообщения>` and no
147
+ part of the payload is logged. The call itself still goes out — the log is built defensively, so a
148
+ logging failure never breaks a request. `metadata` is unaffected and stays correct.
149
+
150
+ ## How the option plugs into grpc-client
151
+
152
+ `with_structlogger` installs `StructlogSink` through `options.with_log_sink` — the public extension
153
+ point added in `grpc-client` 2.1. Nothing here subclasses an interceptor.
154
+
155
+ The split is: `grpc-client` decides **what happened** — it intercepts all four call types, wraps
156
+ response streams to count messages, reads the outcome, builds a `RequestRecord` / `ResponseRecord`
157
+ and picks the level. `StructlogSink` decides **how it is written** — masking, JSON serialization,
158
+ truncation, and the field names.
159
+
160
+ `ResponseRecord` covers three outcomes with one shape, and the sink tells them apart the same way
161
+ the built-in `TextLogSink` does:
162
+
163
+ | Outcome | `status_code` | `message` | `messages_count` | Parser used |
164
+ |---|---|---|---|---|
165
+ | unary success | `OK` | the response | `None` | `parse_response` |
166
+ | stream finished | `OK` | `None` | count | `parse_stream_response` |
167
+ | any failure | the error code | `None` | count for streams, `None` for unary | `parse_error` |
168
+
169
+ Everything the sink uses is public: `RequestRecord` / `ResponseRecord` / `resolve_log_levels` from
170
+ `grpc_client`, `sanitize` / `sanitize_metadata` from `grpc_client.utils.sanitizer`, and
171
+ `LogFormat` / `BytesEncoder` / the marker constants from `grpc_client.utils.logutils`.
172
+
173
+ ## Only `with_structlogger` is public API
174
+
175
+ `__init__` exports that one name. `sink`, `parser` and the constants inside them are internal
176
+ and can move. Filter logs by the event strings if you must, but do not import them.
@@ -0,0 +1,3 @@
1
+ from .option import with_structlogger
2
+
3
+ __all__ = ["with_structlogger"]
@@ -0,0 +1,62 @@
1
+ from collections.abc import Sequence
2
+ from typing import TypedDict, Unpack
3
+
4
+ from grpc_client import BaseGRPCClient, ClientOption, options, resolve_log_levels
5
+ from grpc_client.utils.logutils import LogFormat
6
+
7
+ from .sink import StructLogger, StructlogSink, check_supported_level
8
+
9
+
10
+ class StructLoggerOptions(TypedDict, total=False):
11
+ """Structured logging options."""
12
+
13
+ sanitize_keys: Sequence[str] | None
14
+ json_indent: int | None
15
+ log_level: int | None
16
+ err_log_level: int | None
17
+ payload_maxlength: int | None
18
+ use_default_sanitize_keys: bool
19
+
20
+
21
+ def with_structlogger(logger: StructLogger, **settings: Unpack[StructLoggerOptions]) -> ClientOption:
22
+ """Set a structured logger for gRPC client requests and responses.
23
+
24
+ :param logger: structured logger
25
+ :param sanitize_keys: metadata key names and paths in the format
26
+ [glom](https://glom.readthedocs.io/en/latest/) in the message body,
27
+ whose values should be hidden
28
+ :param json_indent: indentation width for JSON property logging (defaults to no indentation)
29
+ :param log_level: gRPC client logging level, defaults to INFO;
30
+ structlog accepts only DEBUG, INFO, WARNING, ERROR, and CRITICAL
31
+ :param err_log_level: failed request logging level, defaults to ERROR;
32
+ the allowed values are the same
33
+ :param payload_maxlength: maximum logged message body length, defaults to 4096 characters;
34
+ `None` in `grpc_client.configure_logging` removes the limit
35
+ :param use_default_sanitize_keys: whether to hide fields with common secret names
36
+ (see `grpc_client.utils.sanitizer.DEFAULT_SANITIZE_KEYS`)
37
+ at any depth, in addition to `sanitize_keys`
38
+ :returns: option applied to a gRPC client to set its logger
39
+ :raises ValueError: when building the client if structlog does not support the logging level
40
+ """
41
+
42
+ def option(client: BaseGRPCClient[object]) -> None:
43
+ # Read shared settings when building the client rather than creating the option:
44
+ # `grpc_client.configure_logging` may have been called after the option was created.
45
+ levels = resolve_log_levels(settings.get("log_level"), settings.get("err_log_level"))
46
+ # Check the resolved level: unsupported values can come
47
+ # both from an argument and from `configure_logging`.
48
+ check_supported_level("log_level", levels[0])
49
+ check_supported_level("err_log_level", levels[1])
50
+
51
+ sink = StructlogSink(
52
+ logger=logger,
53
+ log_format=LogFormat.from_config(
54
+ sanitize_keys=settings.get("sanitize_keys"),
55
+ json_indent=settings.get("json_indent"),
56
+ payload_maxlength=settings.get("payload_maxlength"),
57
+ use_default_sanitize_keys=settings.get("use_default_sanitize_keys", True),
58
+ ),
59
+ )
60
+ options.with_log_sink(sink, log_level=levels[0], err_log_level=levels[1])(client)
61
+
62
+ return option
@@ -0,0 +1,156 @@
1
+ import json
2
+ import logging
3
+ from typing import cast
4
+
5
+ from google.protobuf.json_format import MessageToDict
6
+ from google.protobuf.message import Message
7
+ from grpc_client import RequestRecord, ResponseRecord
8
+ from grpc_client.types import MetadataType
9
+ from grpc_client.utils.logutils import PAYLOAD_UNAVAILABLE, TRUNCATION_SUFFIX, BytesEncoder, LogFormat
10
+ from grpc_client.utils.sanitizer import sanitize, sanitize_metadata
11
+
12
+ _logger = logging.getLogger(__name__)
13
+
14
+ # `body` field value when the request body is a message stream.
15
+ STREAMED_CONTENT = "<streamed content>"
16
+
17
+ # `status_code` contains the code name rather than its number: names need no reference lookup,
18
+ # and make filtering convenient. A number would add a second value to the field,
19
+ # whereas searching for `2 (UNKNOWN)` would require substring matching.
20
+
21
+ # `trace_id` is appended last to every record rather than set with the other fields:
22
+ # renderers that preserve insertion order (such as `structlog.processors.JSONRenderer`)
23
+ # output fields in this order, and the internal identifier must not split the call data.
24
+
25
+
26
+ def parse_request(record: RequestRecord, log_format: LogFormat) -> dict[str, object]:
27
+ """Build structured log fields for a sent gRPC request.
28
+
29
+ :param record: request data
30
+ :param log_format: log formatting rules
31
+ :returns: structured request log fields
32
+ """
33
+ properties: dict[str, object] = {
34
+ "method": record.method,
35
+ "body": _parse_body(record.message, log_format) if record.message is not None else STREAMED_CONTENT,
36
+ }
37
+ if (metadata_field := _parse_metadata(record.metadata, log_format)) is not None:
38
+ properties["metadata"] = metadata_field
39
+
40
+ properties["trace_id"] = record.trace_id
41
+ return properties
42
+
43
+
44
+ def parse_response(record: ResponseRecord, log_format: LogFormat) -> dict[str, object]:
45
+ """Build structured log fields for a received gRPC response.
46
+
47
+ :param record: completed call data
48
+ :param log_format: log formatting rules
49
+ :returns: structured response log fields
50
+ """
51
+ properties: dict[str, object] = {"method": record.method, "status_code": record.status_code.name}
52
+ if record.message is not None:
53
+ properties["body"] = _parse_body(record.message, log_format)
54
+ if (metadata_field := _parse_metadata(record.metadata, log_format)) is not None:
55
+ properties["metadata"] = metadata_field
56
+
57
+ properties["trace_id"] = record.trace_id
58
+ return properties
59
+
60
+
61
+ def parse_stream_response(record: ResponseRecord, log_format: LogFormat) -> dict[str, object]:
62
+ """Build structured log fields for a completed response message stream.
63
+
64
+ Messages themselves are not logged: a stream may be arbitrarily long, and reading it
65
+ for logging would change call behavior.
66
+
67
+ :param record: completed stream data
68
+ :param log_format: log formatting rules
69
+ :returns: structured log fields for the completed stream
70
+ """
71
+ properties: dict[str, object] = {
72
+ "method": record.method,
73
+ "status_code": record.status_code.name,
74
+ "messages_count": record.messages_count,
75
+ }
76
+ if (metadata_field := _parse_metadata(record.metadata, log_format)) is not None:
77
+ properties["metadata"] = metadata_field
78
+
79
+ properties["trace_id"] = record.trace_id
80
+ return properties
81
+
82
+
83
+ def parse_error(record: ResponseRecord, log_format: LogFormat) -> dict[str, object]:
84
+ """Build structured log fields for a failed call.
85
+
86
+ :param record: completed call data
87
+ :param log_format: log formatting rules
88
+ :returns: structured error log fields
89
+ """
90
+ properties: dict[str, object] = {
91
+ "method": record.method,
92
+ "status_code": record.status_code.name,
93
+ "details": record.details,
94
+ }
95
+ if record.messages_count is not None:
96
+ properties["messages_count"] = record.messages_count
97
+ if (metadata_field := _parse_metadata(record.metadata, log_format)) is not None:
98
+ properties["metadata"] = metadata_field
99
+
100
+ properties["trace_id"] = record.trace_id
101
+ return properties
102
+
103
+
104
+ def _parse_metadata(metadata: MetadataType | None, log_format: LogFormat) -> str | None:
105
+ """Build the metadata field value.
106
+
107
+ Repeated key values are collected in a list: a regular dictionary would retain
108
+ only the last value and make the log differ from the sent metadata.
109
+
110
+ :param metadata: request or response metadata
111
+ :param log_format: log formatting rules
112
+ :returns: metadata as JSON, or None if absent
113
+ """
114
+ items = sanitize_metadata(metadata, log_format.sanitize_keys)
115
+ if not items:
116
+ return None
117
+
118
+ grouped: dict[str, object] = {}
119
+ for name, value in items:
120
+ if name not in grouped:
121
+ grouped[name] = value
122
+ elif isinstance(previous := grouped[name], list):
123
+ cast("list[object]", previous).append(value)
124
+ else:
125
+ grouped[name] = [grouped[name], value]
126
+
127
+ return _to_json(grouped, log_format.json_indent)
128
+
129
+
130
+ def _parse_body(message: Message, log_format: LogFormat) -> str:
131
+ """Build the message body field value.
132
+
133
+ A log preparation error must not block requests: logs are built before sending, and a failure
134
+ here, for example on a deeply nested message, would break the call itself.
135
+
136
+ :param message: request or response message
137
+ :param log_format: log formatting rules
138
+ :returns: message body as JSON or a marker for an unavailable body
139
+ """
140
+ try:
141
+ payload = cast("dict[str, object]", MessageToDict(message, always_print_fields_with_no_presence=True))
142
+ masked = sanitize(payload, log_format.sanitize_keys)
143
+ return _truncate(_to_json(masked, log_format.json_indent), log_format.payload_maxlength)
144
+ except Exception:
145
+ _logger.warning("Не удалось подготовить тело сообщения gRPC", exc_info=True)
146
+ return PAYLOAD_UNAVAILABLE
147
+
148
+
149
+ def _to_json(value: dict[str, object], indent: int | None) -> str:
150
+ return json.dumps(value, indent=indent, ensure_ascii=False, cls=cast("type[json.JSONEncoder]", BytesEncoder))
151
+
152
+
153
+ def _truncate(value: str, maxlength: int | None) -> str:
154
+ if maxlength is None or len(value) <= maxlength:
155
+ return value
156
+ return value[:maxlength] + TRUNCATION_SUFFIX
File without changes
@@ -0,0 +1,95 @@
1
+ import logging
2
+
3
+ import grpc
4
+ from grpc_client import RequestRecord, ResponseRecord
5
+ from grpc_client.utils.logutils import LogFormat
6
+ from structlog.stdlib import BoundLogger
7
+ from structlog.typing import FilteringBoundLogger
8
+
9
+ from .parser import parse_error, parse_request, parse_response, parse_stream_response
10
+
11
+ # Structlog bound logger: stdlib-based or a class from `make_filtering_bound_logger`.
12
+ # The second does not fit the first: its level check methods have different names.
13
+ StructLogger = BoundLogger | FilteringBoundLogger
14
+
15
+ # Sent request event.
16
+ REQUEST_EVENT = "grpc request sent"
17
+
18
+ # Received response event: success, failure, or stream completion.
19
+ RESPONSE_EVENT = "grpc response received"
20
+
21
+ # Levels supported by structlog.
22
+ #
23
+ # A stdlib logger accepts any number, while a structlog bound logger selects a method by level name,
24
+ # and only these five levels have names. A stdlib logger would write an intermediate value such as 15,
25
+ # but structlog raises `KeyError`.
26
+ SUPPORTED_LOG_LEVELS = (logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL)
27
+
28
+
29
+ class StructlogSink:
30
+ """Sink that writes gRPC call records to structlog as separate fields."""
31
+
32
+ def __init__(self, logger: StructLogger, log_format: LogFormat | None = None) -> None:
33
+ """Initialize the object.
34
+
35
+ :param logger: structured logger used for requests and responses
36
+ :param log_format: log formatting rules, defaults to shared configuration
37
+ """
38
+ self._logger = logger
39
+ self._format = log_format if log_format is not None else LogFormat.from_config()
40
+
41
+ def is_enabled_for(self, level: int) -> bool:
42
+ """Check whether the logger writes records at the specified level.
43
+
44
+ The check may fail: users set the bound logger class in `structlog.configure`,
45
+ and `structlog.stdlib.BoundLogger`, for example, delegates the query to its underlying logger,
46
+ while `structlog.PrintLogger` cannot answer it. Treat such a logger as enabled:
47
+ silently losing records is worse than building an extra one, and breaking the
48
+ call itself over this query is even worse.
49
+
50
+ :param level: record level
51
+ :returns: True if a record at this level reaches output
52
+ """
53
+ try:
54
+ return self._logger.is_enabled_for(level)
55
+ except Exception: # noqa: BLE001 - a broken check must not break the call
56
+ return True
57
+
58
+ def write_request(self, level: int, record: RequestRecord) -> None:
59
+ """Log a sent request."""
60
+ self._write(level, REQUEST_EVENT, parse_request(record, self._format))
61
+
62
+ def write_response(self, level: int, record: ResponseRecord) -> None:
63
+ """Log a completed call."""
64
+ self._write(level, RESPONSE_EVENT, self._response_properties(record))
65
+
66
+ def _response_properties(self, record: ResponseRecord) -> dict[str, object]:
67
+ """Build log fields suitable for the call outcome."""
68
+ if record.status_code is not grpc.StatusCode.OK:
69
+ return parse_error(record, self._format)
70
+ if record.messages_count is not None:
71
+ return parse_stream_response(record, self._format)
72
+ return parse_response(record, self._format)
73
+
74
+ def _write(self, level: int, event: str, properties: dict[str, object]) -> None:
75
+ """Write an event with fields to the structured logger."""
76
+ self._logger.log(level, event, **properties)
77
+
78
+
79
+ def check_supported_level(name: str, level: int) -> None:
80
+ """Check whether structlog supports writing at this level.
81
+
82
+ Validate when building the client rather than on the first request: otherwise a logging
83
+ level typo becomes a `KeyError` inside structlog before the request is even sent;
84
+ its record is built before the call.
85
+
86
+ :param name: name of the parameter that supplied the level
87
+ :param level: level to check
88
+ :raises ValueError: if the level is not in `SUPPORTED_LOG_LEVELS`
89
+ """
90
+ if level in SUPPORTED_LOG_LEVELS:
91
+ return
92
+
93
+ supported = ", ".join(f"{logging.getLevelName(value)} ({value})" for value in SUPPORTED_LOG_LEVELS)
94
+ message = f"structlog не умеет писать уровнем {level}: {name} принимает только {supported}"
95
+ raise ValueError(message)