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.
- cloudru_qa_grpc_client_structlog-1.0.0.dist-info/METADATA +240 -0
- cloudru_qa_grpc_client_structlog-1.0.0.dist-info/RECORD +12 -0
- cloudru_qa_grpc_client_structlog-1.0.0.dist-info/WHEEL +4 -0
- cloudru_qa_grpc_client_structlog-1.0.0.dist-info/licenses/LICENSE +202 -0
- grpc_client_structlog/.agents/skills/grpc-client-structlog-usage/SKILL.md +191 -0
- grpc_client_structlog/.agents/skills/grpc-client-structlog-usage/references/fields.md +161 -0
- grpc_client_structlog/.agents/skills/grpc-client-structlog-usage/references/pitfalls.md +176 -0
- grpc_client_structlog/__init__.py +3 -0
- grpc_client_structlog/option.py +62 -0
- grpc_client_structlog/parser.py +156 -0
- grpc_client_structlog/py.typed +0 -0
- grpc_client_structlog/sink.py +95 -0
|
@@ -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
|
+
[](https://pypi.org/project/cloudru-qa-grpc-client-structlog/)
|
|
26
|
+
[](https://pypi.org/project/cloudru-qa-grpc-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 [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,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,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)
|