nui-python-shared-utils 1.4.1__py3-none-any.whl → 1.6.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.
- {nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/METADATA +1 -1
- {nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/RECORD +12 -8
- {nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/WHEEL +1 -1
- nui_shared_utils/__init__.py +39 -0
- nui_shared_utils/logging/__init__.py +68 -0
- nui_shared_utils/logging/binding.py +355 -0
- nui_shared_utils/logging/fields.py +253 -0
- nui_shared_utils/logging/redaction.py +189 -0
- nui_shared_utils/slack_client.py +16 -3
- {nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/entry_points.txt +0 -0
- {nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/top_level.txt +0 -0
{nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nui-python-shared-utils
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.6.0
|
|
4
4
|
Summary: Shared Python utilities for AWS Lambda, CLI tools, and agents with Slack, Elasticsearch, and monitoring integrations
|
|
5
5
|
Home-page: https://github.com/nuimarkets/nui-python-shared-utils
|
|
6
6
|
Author: NUI Markets
|
|
@@ -21,8 +21,8 @@ nui_lambda_shared_utils/slack_setup/__init__.py,sha256=q78NzSznsSd3ri5OUohWxQt7r
|
|
|
21
21
|
nui_lambda_shared_utils/slack_setup/channel_creator.py,sha256=xaeqzyEAqVqYj2EhN69UvcyZQ6LdFKYODPPCwxHInLA,172
|
|
22
22
|
nui_lambda_shared_utils/slack_setup/channel_definitions.py,sha256=3TkdLzWvEMBMDy5jqOPMUGRgKQ3z9TN58D43V7O7LpE,180
|
|
23
23
|
nui_lambda_shared_utils/slack_setup/setup_helpers.py,sha256=ex_RiUfiZqH9qGLQwmedezMbfp35uI-VQPevducfbBk,168
|
|
24
|
-
nui_python_shared_utils-1.
|
|
25
|
-
nui_shared_utils/__init__.py,sha256=
|
|
24
|
+
nui_python_shared_utils-1.6.0.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
|
|
25
|
+
nui_shared_utils/__init__.py,sha256=vFS5oaw7_iHgHnLRifDYYyb7au6G2nYJ6ztqMs-hrQs,13289
|
|
26
26
|
nui_shared_utils/base_client.py,sha256=I1lKQGhrKyvujV2zps0TrtEdPDqMCwRQaIh6ceGONXQ,11016
|
|
27
27
|
nui_shared_utils/cli.py,sha256=JJpSoQWKvAz4b8cO30yFNi5vY9jmqrCHzbFpvrVTkbU,8747
|
|
28
28
|
nui_shared_utils/cloudwatch_metrics.py,sha256=p0J-s63UJDGK14TRGt0k8yoj7aWSSM55UKdgcuaT1dU,12098
|
|
@@ -37,17 +37,21 @@ nui_shared_utils/llm.py,sha256=adNUO8Qbeb3__GvpROxWSUERkpiazKiGJFE5xzU5eZ0,8351
|
|
|
37
37
|
nui_shared_utils/log_processors.py,sha256=x5gz1LEkbmoMCZ3ZB6q-mbn26dSzwh1trz4wSkBGxqk,5538
|
|
38
38
|
nui_shared_utils/powertools_helpers.py,sha256=qc-lYLfY-QX20gALfPOJcihrtJhiCU-fJW8rsPT7LJE,11037
|
|
39
39
|
nui_shared_utils/secrets_helper.py,sha256=_jQkMWzjayZ8vvyHG2yuuxur-SQYUUONfcsZ0MF8qOw,7025
|
|
40
|
-
nui_shared_utils/slack_client.py,sha256=
|
|
40
|
+
nui_shared_utils/slack_client.py,sha256=wXE0D4T4FWt1giz9Xmp71RustpaJYeLAZo6JxulDVko,25550
|
|
41
41
|
nui_shared_utils/slack_formatter.py,sha256=95g6XfAJst7RVhd0M0ahiF3gWWW5j96WYkCg5tLS6Zg,10894
|
|
42
42
|
nui_shared_utils/snowflake_client.py,sha256=e3jH6_2mMqbtP1TE8-mUFFYrfGW9eqUPT5tlcgeCqx8,16181
|
|
43
43
|
nui_shared_utils/timezone.py,sha256=TvtSZV7w3Vvz3NbMTcJSNbUf0ZxPwpFS-xfgye1h-4Q,3583
|
|
44
44
|
nui_shared_utils/utils.py,sha256=gOpw80tZGC24QsyesR8EySRODY-TFXG8cXUvggPBsRI,8184
|
|
45
|
+
nui_shared_utils/logging/__init__.py,sha256=gVC853q3ja4RhcBVbf5jKO10lFX4upfsTvkKZx4wu54,1805
|
|
46
|
+
nui_shared_utils/logging/binding.py,sha256=I5VNmtf4U2yt54aS4F0Djw4u22mfjwCpWvORUnJNkPU,13577
|
|
47
|
+
nui_shared_utils/logging/fields.py,sha256=7jhBdyEXAJod61aKPCk68idECTAV-_BNjiABp9IRFZo,11194
|
|
48
|
+
nui_shared_utils/logging/redaction.py,sha256=t-EtNhR1nK6IOae70GnieHxn_36_jYuuta32T1dMlP8,6928
|
|
45
49
|
nui_shared_utils/slack_setup/__init__.py,sha256=OElyS3xk4F_YKH5uUUTDpN0ah1dOO3e52muIPjAMPW8,320
|
|
46
50
|
nui_shared_utils/slack_setup/channel_creator.py,sha256=0gyCBIS0EC96SVn1Z2dD4Fpzk0xNdHSWyiCxoUQIUe8,10667
|
|
47
51
|
nui_shared_utils/slack_setup/channel_definitions.py,sha256=atfz5ZhpqefOeLh1gShbWd-TLzgjPmhv95bfuTdmZog,5676
|
|
48
52
|
nui_shared_utils/slack_setup/setup_helpers.py,sha256=pzzXMs12GI9sdZttWeYzgLPgC0xqPz7ZLHM1GUPNNXc,7219
|
|
49
|
-
nui_python_shared_utils-1.
|
|
50
|
-
nui_python_shared_utils-1.
|
|
51
|
-
nui_python_shared_utils-1.
|
|
52
|
-
nui_python_shared_utils-1.
|
|
53
|
-
nui_python_shared_utils-1.
|
|
53
|
+
nui_python_shared_utils-1.6.0.dist-info/METADATA,sha256=SBa2JHGtMr1R5x1PTfCp0WBkVYZB3XnLqcPIwUgYOXM,22632
|
|
54
|
+
nui_python_shared_utils-1.6.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
55
|
+
nui_python_shared_utils-1.6.0.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
|
|
56
|
+
nui_python_shared_utils-1.6.0.dist-info/top_level.txt,sha256=sNceq5okmEB54L-gm4a0OgGhnPlU62zYmlUoXKn-Fa8,41
|
|
57
|
+
nui_python_shared_utils-1.6.0.dist-info/RECORD,,
|
nui_shared_utils/__init__.py
CHANGED
|
@@ -120,6 +120,25 @@ _LAZY_EXPORTS = {
|
|
|
120
120
|
"build_anthropic_client": ("llm", "build_anthropic_client"),
|
|
121
121
|
"call_tool": ("llm", "call_tool"),
|
|
122
122
|
"call_text": ("llm", "call_text"),
|
|
123
|
+
# Structured logging contract (stdlib-only field registry + binders)
|
|
124
|
+
"F": ("logging", "F"),
|
|
125
|
+
"FIELDS": ("logging", "FIELDS"),
|
|
126
|
+
"Field": ("logging", "Field"),
|
|
127
|
+
"validate_fields": ("logging", "validate_fields"),
|
|
128
|
+
"validate_registry": ("logging", "validate_registry"),
|
|
129
|
+
"redaction_keys": ("logging", "redaction_keys"),
|
|
130
|
+
"alias_map": ("logging", "alias_map"),
|
|
131
|
+
"default_redactor": ("logging", "default_redactor"),
|
|
132
|
+
"powertools_binder": ("logging", "powertools_binder"),
|
|
133
|
+
"stdlib_binder": ("logging", "stdlib_binder"),
|
|
134
|
+
"PowertoolsBinder": ("logging", "PowertoolsBinder"),
|
|
135
|
+
"StdlibBinder": ("logging", "StdlibBinder"),
|
|
136
|
+
"bind_request": ("logging", "bind_request"),
|
|
137
|
+
"bind_user": ("logging", "bind_user"),
|
|
138
|
+
"bind_build_context": ("logging", "bind_build_context"),
|
|
139
|
+
"log_exception": ("logging", "log_exception"),
|
|
140
|
+
"request_body_fields": ("logging", "request_body_fields"),
|
|
141
|
+
"response_output_fields": ("logging", "response_output_fields"),
|
|
123
142
|
}
|
|
124
143
|
|
|
125
144
|
# Submodules that are optional integrations; ImportError during lazy load
|
|
@@ -275,6 +294,26 @@ if TYPE_CHECKING:
|
|
|
275
294
|
validate_jwt,
|
|
276
295
|
)
|
|
277
296
|
from .llm import build_anthropic_client, call_text, call_tool
|
|
297
|
+
from .logging import (
|
|
298
|
+
FIELDS,
|
|
299
|
+
F,
|
|
300
|
+
Field,
|
|
301
|
+
PowertoolsBinder,
|
|
302
|
+
StdlibBinder,
|
|
303
|
+
alias_map,
|
|
304
|
+
bind_build_context,
|
|
305
|
+
bind_request,
|
|
306
|
+
bind_user,
|
|
307
|
+
default_redactor,
|
|
308
|
+
log_exception,
|
|
309
|
+
powertools_binder,
|
|
310
|
+
redaction_keys,
|
|
311
|
+
request_body_fields,
|
|
312
|
+
response_output_fields,
|
|
313
|
+
stdlib_binder,
|
|
314
|
+
validate_fields,
|
|
315
|
+
validate_registry,
|
|
316
|
+
)
|
|
278
317
|
from . import slack_setup
|
|
279
318
|
|
|
280
319
|
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Structured logging contract: canonical field registry + backend-agnostic binders.
|
|
2
|
+
|
|
3
|
+
The fleet's dotted, snake_case logging vocabulary and the helpers that emit it.
|
|
4
|
+
Stdlib-only, so importing this package pulls in no third-party dependency (the
|
|
5
|
+
Powertools binder calls ``append_keys`` / ``remove_keys`` on a logger the caller
|
|
6
|
+
constructs; it never imports ``aws_lambda_powertools`` itself).
|
|
7
|
+
|
|
8
|
+
Typical use::
|
|
9
|
+
|
|
10
|
+
from nui_shared_utils.logging import F, stdlib_binder, bind_request, bind_user
|
|
11
|
+
|
|
12
|
+
binder = stdlib_binder(logger)
|
|
13
|
+
with binder.request_scope():
|
|
14
|
+
bind_request(binder, method="POST", path="/orders", request_id=rid)
|
|
15
|
+
bind_user(binder, id=u.id, email=u.email, org_id=u.org_id)
|
|
16
|
+
logger.info("response", extra={F.RESPONSE_STATUS: 200})
|
|
17
|
+
|
|
18
|
+
See ``docs/guides/structured-logging-contract.md`` for the full guide and
|
|
19
|
+
``docs/decisions/ADR-001-structured-logging-field-vocabulary.md`` for the rationale.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from .binding import (
|
|
23
|
+
PowertoolsBinder,
|
|
24
|
+
StdlibBinder,
|
|
25
|
+
bind_build_context,
|
|
26
|
+
bind_request,
|
|
27
|
+
bind_user,
|
|
28
|
+
log_exception,
|
|
29
|
+
powertools_binder,
|
|
30
|
+
request_body_fields,
|
|
31
|
+
response_output_fields,
|
|
32
|
+
stdlib_binder,
|
|
33
|
+
)
|
|
34
|
+
from .fields import (
|
|
35
|
+
FIELDS,
|
|
36
|
+
F,
|
|
37
|
+
Field,
|
|
38
|
+
alias_map,
|
|
39
|
+
redaction_keys,
|
|
40
|
+
validate_fields,
|
|
41
|
+
validate_registry,
|
|
42
|
+
)
|
|
43
|
+
from .redaction import MASK, default_redactor
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# registry
|
|
47
|
+
"F",
|
|
48
|
+
"FIELDS",
|
|
49
|
+
"Field",
|
|
50
|
+
"validate_fields",
|
|
51
|
+
"validate_registry",
|
|
52
|
+
"redaction_keys",
|
|
53
|
+
"alias_map",
|
|
54
|
+
# redaction
|
|
55
|
+
"default_redactor",
|
|
56
|
+
"MASK",
|
|
57
|
+
# binders + helpers
|
|
58
|
+
"powertools_binder",
|
|
59
|
+
"stdlib_binder",
|
|
60
|
+
"PowertoolsBinder",
|
|
61
|
+
"StdlibBinder",
|
|
62
|
+
"bind_request",
|
|
63
|
+
"bind_user",
|
|
64
|
+
"bind_build_context",
|
|
65
|
+
"log_exception",
|
|
66
|
+
"request_body_fields",
|
|
67
|
+
"response_output_fields",
|
|
68
|
+
]
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Backend-agnostic, request-scoped binding helpers for structured logging.
|
|
2
|
+
|
|
3
|
+
Two logging backends are in use across the fleet and this module drives both from
|
|
4
|
+
one call site:
|
|
5
|
+
|
|
6
|
+
- **Powertools** (:func:`powertools_binder`): persistent context via
|
|
7
|
+
``Logger.append_keys`` / ``remove_keys``. Used by short-lived Lambda functions.
|
|
8
|
+
- **stdlib** (:func:`stdlib_binder`): a ``contextvars``-backed ``logging.Filter``
|
|
9
|
+
that injects bound fields onto each record. Used by long-running services on the
|
|
10
|
+
``pythonjsonlogger`` path, which has no native persistent context.
|
|
11
|
+
|
|
12
|
+
Both binders are **request-scoped**: :meth:`request_scope` sets a ``contextvars``
|
|
13
|
+
token on entry and unbinds on exit, so bound context cannot leak across concurrent
|
|
14
|
+
requests on a long-running service or across warm Lambda invocations. ``contextvars``
|
|
15
|
+
(not thread-locals) so isolation holds under ``asyncio`` too. Wire ``request_scope``
|
|
16
|
+
into the request-end tween / handler ``finally``.
|
|
17
|
+
|
|
18
|
+
The backend is chosen **once at construction**, not by ``hasattr`` duck-typing
|
|
19
|
+
(which breaks on ``LoggerAdapter`` and other wrappers).
|
|
20
|
+
|
|
21
|
+
binder = powertools_binder(logger) # or stdlib_binder(logger)
|
|
22
|
+
with binder.request_scope():
|
|
23
|
+
bind_request(binder, method="POST", path=p, request_id=rid)
|
|
24
|
+
bind_user(binder, id=u.id, email=u.email, org_id=u.org_id)
|
|
25
|
+
logger.info("response", extra={F.RESPONSE_STATUS: 200}) # carries bound keys
|
|
26
|
+
# on exit: bound keys removed, no cross-request / cross-invocation leakage
|
|
27
|
+
|
|
28
|
+
Values are always emitted as dotted keys (``request.method``), never nested dicts.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import contextvars
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import traceback
|
|
37
|
+
from contextlib import contextmanager
|
|
38
|
+
from typing import Any, Dict, Iterable, Iterator, Mapping, Optional
|
|
39
|
+
|
|
40
|
+
from .fields import F
|
|
41
|
+
from .redaction import default_redactor
|
|
42
|
+
|
|
43
|
+
# Monotonic counter so each binder gets a distinct ContextVar name (ContextVar
|
|
44
|
+
# names are for debugging only, but distinct names avoid confusion in tracebacks).
|
|
45
|
+
_binder_seq = 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _next_seq() -> int:
|
|
49
|
+
global _binder_seq
|
|
50
|
+
_binder_seq += 1
|
|
51
|
+
return _binder_seq
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _drop_none(mapping: Mapping[str, Any]) -> Dict[str, Any]:
|
|
55
|
+
"""Return ``mapping`` without keys whose value is ``None``."""
|
|
56
|
+
return {k: v for k, v in mapping.items() if v is not None}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PowertoolsBinder:
|
|
60
|
+
"""Request-scoped binder over an AWS Powertools ``Logger``.
|
|
61
|
+
|
|
62
|
+
Binding calls ``logger.append_keys``; :meth:`request_scope` tracks the keys
|
|
63
|
+
bound within the scope and calls ``logger.remove_keys`` on exit so a warm
|
|
64
|
+
invocation does not inherit the previous request's context.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(self, logger: Any):
|
|
68
|
+
self.logger = logger
|
|
69
|
+
self._scope_keys: contextvars.ContextVar[Optional[set]] = contextvars.ContextVar(
|
|
70
|
+
f"nui_log_pt_scope_{_next_seq()}", default=None
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def bind(self, mapping: Mapping[str, Any]) -> None:
|
|
74
|
+
"""Append the given dotted keys to the logger context (``None`` dropped)."""
|
|
75
|
+
clean = _drop_none(mapping)
|
|
76
|
+
if not clean:
|
|
77
|
+
return
|
|
78
|
+
self.logger.append_keys(**clean)
|
|
79
|
+
tracked = self._scope_keys.get()
|
|
80
|
+
if tracked is not None:
|
|
81
|
+
tracked.update(clean.keys())
|
|
82
|
+
|
|
83
|
+
def unbind(self, keys: Iterable[str]) -> None:
|
|
84
|
+
"""Remove the given keys from the logger context."""
|
|
85
|
+
keys = list(keys)
|
|
86
|
+
if keys:
|
|
87
|
+
self.logger.remove_keys(keys)
|
|
88
|
+
|
|
89
|
+
@contextmanager
|
|
90
|
+
def request_scope(self) -> Iterator["PowertoolsBinder"]:
|
|
91
|
+
"""Scope bound keys to the block and restore prior state on exit.
|
|
92
|
+
|
|
93
|
+
Keys bound inside the block are removed on exit, but a key that already held
|
|
94
|
+
a process-level value before the scope (e.g. build context bound at startup
|
|
95
|
+
and then rebound in-request) is restored to that prior value rather than
|
|
96
|
+
removed. Restoration uses the logger's ``get_current_keys`` when available;
|
|
97
|
+
without it, scoped keys are simply removed.
|
|
98
|
+
"""
|
|
99
|
+
get_keys = getattr(self.logger, "get_current_keys", None)
|
|
100
|
+
prior = dict(get_keys()) if callable(get_keys) else None
|
|
101
|
+
token = self._scope_keys.set(set())
|
|
102
|
+
try:
|
|
103
|
+
yield self
|
|
104
|
+
finally:
|
|
105
|
+
scoped = self._scope_keys.get() or set()
|
|
106
|
+
if scoped:
|
|
107
|
+
if prior is not None:
|
|
108
|
+
added = [k for k in scoped if k not in prior]
|
|
109
|
+
restore = {k: prior[k] for k in scoped if k in prior}
|
|
110
|
+
if added:
|
|
111
|
+
self.logger.remove_keys(added)
|
|
112
|
+
if restore:
|
|
113
|
+
self.logger.append_keys(**restore)
|
|
114
|
+
else:
|
|
115
|
+
self.logger.remove_keys(list(scoped))
|
|
116
|
+
self._scope_keys.reset(token)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _ContextVarLogFilter(logging.Filter):
|
|
120
|
+
"""A ``logging.Filter`` that injects the current bound fields onto each record.
|
|
121
|
+
|
|
122
|
+
Reads the binder's ``contextvars`` value at emit time, so concurrent requests
|
|
123
|
+
(each in its own task / thread context) see only their own bound fields. An
|
|
124
|
+
explicit per-call ``extra=`` value already on the record wins (bound context
|
|
125
|
+
never clobbers it).
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, ctx: "contextvars.ContextVar[Optional[Dict[str, Any]]]"):
|
|
129
|
+
super().__init__()
|
|
130
|
+
self._ctx = ctx
|
|
131
|
+
|
|
132
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
133
|
+
fields = self._ctx.get()
|
|
134
|
+
if fields:
|
|
135
|
+
for key, value in fields.items():
|
|
136
|
+
if not hasattr(record, key):
|
|
137
|
+
setattr(record, key, value)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class StdlibBinder:
|
|
142
|
+
"""Request-scoped binder over a standard-library ``logging.Logger``.
|
|
143
|
+
|
|
144
|
+
Bound fields live in a ``contextvars.ContextVar`` and are injected onto every
|
|
145
|
+
record by a filter installed on the logger. :meth:`request_scope` snapshots the
|
|
146
|
+
context on entry and resets it on exit, so a long-running service does not bleed
|
|
147
|
+
one request's context into another (async-safe under ``contextvars``).
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def __init__(self, logger: logging.Logger):
|
|
151
|
+
self.logger = logger
|
|
152
|
+
self._ctx: contextvars.ContextVar[Optional[Dict[str, Any]]] = contextvars.ContextVar(
|
|
153
|
+
f"nui_log_std_ctx_{_next_seq()}", default=None
|
|
154
|
+
)
|
|
155
|
+
self._filter = _ContextVarLogFilter(self._ctx)
|
|
156
|
+
self.install_on(logger)
|
|
157
|
+
|
|
158
|
+
def install_on(self, target: Any) -> None:
|
|
159
|
+
"""Attach the context filter to a ``Logger`` or ``Handler`` (idempotent).
|
|
160
|
+
|
|
161
|
+
A logger's *filters* run only for records emitted via that exact logger, not
|
|
162
|
+
for records propagated up from child loggers (``getLogger("svc.module")``).
|
|
163
|
+
A *handler's* filters, by contrast, run for every record that reaches the
|
|
164
|
+
handler, including propagated ones. So the filter is installed on the
|
|
165
|
+
logger (direct emits) and on the effective handlers up the hierarchy (child
|
|
166
|
+
emits). The filter is a no-op outside a ``request_scope`` (empty context),
|
|
167
|
+
so attaching it to a shared / root handler is safe.
|
|
168
|
+
|
|
169
|
+
Call this explicitly for handlers configured *after* the binder is built, or
|
|
170
|
+
when your JSON handler lives on a logger other than the one passed here.
|
|
171
|
+
"""
|
|
172
|
+
if isinstance(target, logging.Handler):
|
|
173
|
+
if self._filter not in target.filters:
|
|
174
|
+
target.addFilter(self._filter)
|
|
175
|
+
return
|
|
176
|
+
if self._filter not in target.filters:
|
|
177
|
+
target.addFilter(self._filter)
|
|
178
|
+
node: Optional[logging.Logger] = target
|
|
179
|
+
while node is not None:
|
|
180
|
+
for handler in node.handlers:
|
|
181
|
+
if self._filter not in handler.filters:
|
|
182
|
+
handler.addFilter(self._filter)
|
|
183
|
+
if not node.propagate:
|
|
184
|
+
break
|
|
185
|
+
node = node.parent
|
|
186
|
+
|
|
187
|
+
def bind(self, mapping: Mapping[str, Any]) -> None:
|
|
188
|
+
"""Merge the given dotted keys into the current context (``None`` dropped)."""
|
|
189
|
+
clean = _drop_none(mapping)
|
|
190
|
+
if not clean:
|
|
191
|
+
return
|
|
192
|
+
current = self._ctx.get()
|
|
193
|
+
merged = dict(current) if current else {}
|
|
194
|
+
merged.update(clean)
|
|
195
|
+
self._ctx.set(merged)
|
|
196
|
+
|
|
197
|
+
def unbind(self, keys: Iterable[str]) -> None:
|
|
198
|
+
"""Remove the given keys from the current context."""
|
|
199
|
+
current = self._ctx.get()
|
|
200
|
+
if not current:
|
|
201
|
+
return
|
|
202
|
+
drop = set(keys)
|
|
203
|
+
self._ctx.set({k: v for k, v in current.items() if k not in drop})
|
|
204
|
+
|
|
205
|
+
def current_fields(self) -> Dict[str, Any]:
|
|
206
|
+
"""Return a snapshot of the currently-bound fields (introspection / tests)."""
|
|
207
|
+
current = self._ctx.get()
|
|
208
|
+
return dict(current) if current else {}
|
|
209
|
+
|
|
210
|
+
@contextmanager
|
|
211
|
+
def request_scope(self) -> Iterator["StdlibBinder"]:
|
|
212
|
+
"""Isolate binds made inside the block; restore prior context on exit."""
|
|
213
|
+
current = self._ctx.get()
|
|
214
|
+
token = self._ctx.set(dict(current) if current else {})
|
|
215
|
+
try:
|
|
216
|
+
yield self
|
|
217
|
+
finally:
|
|
218
|
+
self._ctx.reset(token)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def powertools_binder(logger: Any) -> PowertoolsBinder:
|
|
222
|
+
"""Build a request-scoped binder over an AWS Powertools ``Logger``."""
|
|
223
|
+
return PowertoolsBinder(logger)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def stdlib_binder(logger: logging.Logger) -> StdlibBinder:
|
|
227
|
+
"""Build a request-scoped binder over a standard-library ``logging.Logger``."""
|
|
228
|
+
return StdlibBinder(logger)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# --- Field-specific binding helpers (callers never hand-type a dotted string) ---
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def bind_request(
|
|
235
|
+
binder: Any,
|
|
236
|
+
*,
|
|
237
|
+
method: Optional[str] = None,
|
|
238
|
+
path: Optional[str] = None,
|
|
239
|
+
ip: Optional[str] = None,
|
|
240
|
+
request_id: Optional[str] = None,
|
|
241
|
+
) -> None:
|
|
242
|
+
"""Bind request context (``request.method`` / ``.path`` / ``.ip`` / ``.id``)."""
|
|
243
|
+
binder.bind(
|
|
244
|
+
{
|
|
245
|
+
F.REQUEST_METHOD: method,
|
|
246
|
+
F.REQUEST_PATH: path,
|
|
247
|
+
F.REQUEST_IP: ip,
|
|
248
|
+
F.REQUEST_ID: request_id,
|
|
249
|
+
}
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def bind_user(
|
|
254
|
+
binder: Any,
|
|
255
|
+
*,
|
|
256
|
+
id: Optional[str] = None,
|
|
257
|
+
email: Optional[str] = None,
|
|
258
|
+
org_id: Optional[str] = None,
|
|
259
|
+
roles: Optional[Any] = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Bind principal identity (``user.id`` / ``.email`` / ``.org_id`` / ``.roles``)."""
|
|
262
|
+
binder.bind(
|
|
263
|
+
{
|
|
264
|
+
F.USER_ID: id,
|
|
265
|
+
F.USER_EMAIL: email,
|
|
266
|
+
F.USER_ORG_ID: org_id,
|
|
267
|
+
F.USER_ROLES: roles,
|
|
268
|
+
}
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def bind_build_context(
|
|
273
|
+
binder: Any,
|
|
274
|
+
*,
|
|
275
|
+
commit: Optional[str] = None,
|
|
276
|
+
branch: Optional[str] = None,
|
|
277
|
+
tag: Optional[str] = None,
|
|
278
|
+
) -> None:
|
|
279
|
+
"""Bind optional build metadata (``git.commit`` / ``.branch`` / ``.tag``).
|
|
280
|
+
|
|
281
|
+
Not bound automatically; the consumer decides whether build metadata is
|
|
282
|
+
available at runtime and calls this once at startup / per request.
|
|
283
|
+
"""
|
|
284
|
+
binder.bind(
|
|
285
|
+
{
|
|
286
|
+
F.GIT_COMMIT: commit,
|
|
287
|
+
F.GIT_BRANCH: branch,
|
|
288
|
+
F.GIT_TAG: tag,
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def log_exception(binder: Any, exc: BaseException, message: str) -> None:
|
|
294
|
+
"""Log ``exc`` at ERROR with explicit ``error.*`` fields.
|
|
295
|
+
|
|
296
|
+
``error.stack`` is set explicitly from the traceback so the canonical field
|
|
297
|
+
always carries the trace. This uses ``logger.error`` (not ``logger.exception``)
|
|
298
|
+
deliberately: ``logger.exception`` sets ``exc_info=True``, which makes the
|
|
299
|
+
backend emit its *own* traceback field (Powertools' ``stack_trace``, stdlib's
|
|
300
|
+
``exc_text``) on top of ``error.stack`` when called inside an ``except`` block,
|
|
301
|
+
i.e. the same trace twice under two names. With ``logger.error``, ``error.stack``
|
|
302
|
+
is the single source.
|
|
303
|
+
"""
|
|
304
|
+
binder.logger.error(
|
|
305
|
+
message,
|
|
306
|
+
extra={
|
|
307
|
+
F.ERROR_TYPE: type(exc).__name__,
|
|
308
|
+
F.ERROR_MESSAGE: str(exc),
|
|
309
|
+
# 3-arg form for Python 3.9 compatibility (single-arg added in 3.10).
|
|
310
|
+
F.ERROR_STACK: "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _redact_kwargs(allow_only: Optional[Iterable[str]], deny_extra: Optional[Iterable[str]]) -> Dict[str, Any]:
|
|
316
|
+
# ``deny_extra`` is only forwarded when set, so a custom ``redactor`` that does
|
|
317
|
+
# not accept it still works when the caller does not use it.
|
|
318
|
+
kwargs: Dict[str, Any] = {"allow_only": allow_only}
|
|
319
|
+
if deny_extra is not None:
|
|
320
|
+
kwargs["deny_extra"] = deny_extra
|
|
321
|
+
return kwargs
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def request_body_fields(
|
|
325
|
+
payload: Any,
|
|
326
|
+
*,
|
|
327
|
+
allow_only: Optional[Iterable[str]] = None,
|
|
328
|
+
deny_extra: Optional[Iterable[str]] = None,
|
|
329
|
+
redactor: Any = default_redactor,
|
|
330
|
+
) -> Dict[str, str]:
|
|
331
|
+
"""Redact and serialise a request body into a single ``request.body_json`` field.
|
|
332
|
+
|
|
333
|
+
Returns a mapping ready to splat into ``extra=`` (or pass to ``binder.bind``).
|
|
334
|
+
The body is redacted (deny-list by default, allow-list via ``allow_only``,
|
|
335
|
+
extended for this call via ``deny_extra``) then JSON-serialised with
|
|
336
|
+
``default=str`` so non-serialisable values do not raise.
|
|
337
|
+
"""
|
|
338
|
+
redacted = redactor(payload, **_redact_kwargs(allow_only, deny_extra))
|
|
339
|
+
return {F.REQUEST_BODY_JSON: json.dumps(redacted, default=str)}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def response_output_fields(
|
|
343
|
+
payload: Any,
|
|
344
|
+
*,
|
|
345
|
+
allow_only: Optional[Iterable[str]] = None,
|
|
346
|
+
deny_extra: Optional[Iterable[str]] = None,
|
|
347
|
+
redactor: Any = default_redactor,
|
|
348
|
+
) -> Dict[str, str]:
|
|
349
|
+
"""Redact and serialise a response payload into a single ``response.output_json`` field.
|
|
350
|
+
|
|
351
|
+
The response twin of :func:`request_body_fields`, for services that log
|
|
352
|
+
unbounded response bodies.
|
|
353
|
+
"""
|
|
354
|
+
redacted = redactor(payload, **_redact_kwargs(allow_only, deny_extra))
|
|
355
|
+
return {F.RESPONSE_OUTPUT_JSON: json.dumps(redacted, default=str)}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Canonical structured-logging field registry (the fleet's logging contract).
|
|
2
|
+
|
|
3
|
+
This module is the single source of truth for the dotted, snake_case field names
|
|
4
|
+
that structured logs use. It is deliberately **stdlib-only** so it stays cheap to
|
|
5
|
+
import and light in a Lambda bundle: no elasticsearch, powertools, or third-party
|
|
6
|
+
dependency is touched here.
|
|
7
|
+
|
|
8
|
+
Two layers:
|
|
9
|
+
|
|
10
|
+
- A **generic core** registry (:data:`FIELDS`) of namespaces every web / event
|
|
11
|
+
service has: ``request.*``, ``user.*``, ``route.*``, ``response.*``,
|
|
12
|
+
``error.*``, ``trace.*``, ``git.*``, plus the ``target`` routing field and the
|
|
13
|
+
opaque ``request.body_json`` / ``response.output_json`` retrieval fields.
|
|
14
|
+
- An **extension pattern**: domain fields (``order.*``, ``tender.*``, ``api.*``,
|
|
15
|
+
``auth.*``, ...) are defined by each consumer in its own constants module and
|
|
16
|
+
drift-checked with :func:`validate_fields`, so the shared package stays generic.
|
|
17
|
+
|
|
18
|
+
Each :class:`Field` carries a type, an indexed flag, a sensitivity, an owner, and
|
|
19
|
+
any deprecated aliases. That one structure is the derive-once source for two lists
|
|
20
|
+
that were otherwise hand-maintained:
|
|
21
|
+
|
|
22
|
+
- :func:`redaction_keys` -- the default redaction deny set (from ``sensitivity``).
|
|
23
|
+
- :func:`alias_map` -- the old-name -> canonical-name migration map (from ``aliases``).
|
|
24
|
+
|
|
25
|
+
The contract is **store-neutral**: it governs how services *emit* fields, not how
|
|
26
|
+
any log store indexes them. Its value is a consistent, typed field vocabulary
|
|
27
|
+
across the fleet, which matters under any backend. Schema-enforcing stores turn
|
|
28
|
+
field drift into loud errors; schema-less stores accept the drift silently and let
|
|
29
|
+
queries and alerts skew. A producer-side registry prevents both.
|
|
30
|
+
|
|
31
|
+
Wire shape is **dotted** (``request.method``), not nested dicts: dotted keys
|
|
32
|
+
survive Powertools ``append_keys`` incremental binding where nested dicts clobber
|
|
33
|
+
(a producer-side property, independent of the store). See
|
|
34
|
+
``docs/decisions/ADR-001-structured-logging-field-vocabulary.md``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import re
|
|
40
|
+
from dataclasses import dataclass, field as _dc_field
|
|
41
|
+
from typing import Dict, List, Tuple
|
|
42
|
+
|
|
43
|
+
# Leaf datatypes a field may declare. These document producer intent (what a field
|
|
44
|
+
# is meant to hold) and give a schema-enforcing store a type to map to; a schema-less
|
|
45
|
+
# store coerces at query time, so the declared type is what keeps emitters honest.
|
|
46
|
+
_KNOWN_TYPES = frozenset({"keyword", "text", "long", "boolean", "date", "geo_point"})
|
|
47
|
+
# Sensitivity levels that drive default redaction.
|
|
48
|
+
_SENSITIVITIES = frozenset({"none", "pii", "redact"})
|
|
49
|
+
# A single dotted segment: lowercase snake_case, must start with a letter.
|
|
50
|
+
_SEGMENT_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Field:
|
|
55
|
+
"""One canonical log field.
|
|
56
|
+
|
|
57
|
+
Attributes:
|
|
58
|
+
key: Dotted, snake_case field name (e.g. ``"request.method"``).
|
|
59
|
+
type: Leaf datatype the field is meant to hold (``keyword`` / ``text`` /
|
|
60
|
+
``long`` / ``boolean`` / ``date`` / ``geo_point``). Producer intent that
|
|
61
|
+
keeps emitters consistent; not enforced by this module.
|
|
62
|
+
indexed: ``False`` marks a retrieval-only field: stored for lookup but not
|
|
63
|
+
meant to be queried or faceted (opaque, unbounded payloads like
|
|
64
|
+
``request.body_json``).
|
|
65
|
+
sensitivity: ``none`` | ``pii`` | ``redact``. ``pii`` / ``redact`` fields
|
|
66
|
+
contribute their leaf name to the default redaction deny set.
|
|
67
|
+
owner: Namespace owner; ``core`` for shared fields.
|
|
68
|
+
aliases: Deprecated dotted names that map to this field, used to build the
|
|
69
|
+
migration / dual-read map.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
key: str
|
|
73
|
+
type: str
|
|
74
|
+
indexed: bool = True
|
|
75
|
+
sensitivity: str = "none"
|
|
76
|
+
owner: str = "core"
|
|
77
|
+
aliases: Tuple[str, ...] = _dc_field(default=())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class F:
|
|
81
|
+
"""Field-name constants for ergonomic call sites (``F.REQUEST_METHOD``).
|
|
82
|
+
|
|
83
|
+
Every constant here has a matching entry in :data:`FIELDS`; the invariant is
|
|
84
|
+
asserted by :func:`validate_registry` at import time.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
# request.*
|
|
88
|
+
REQUEST_ID = "request.id"
|
|
89
|
+
REQUEST_METHOD = "request.method"
|
|
90
|
+
REQUEST_PATH = "request.path"
|
|
91
|
+
REQUEST_IP = "request.ip"
|
|
92
|
+
REQUEST_BODY_JSON = "request.body_json"
|
|
93
|
+
# user.*
|
|
94
|
+
USER_ID = "user.id"
|
|
95
|
+
USER_EMAIL = "user.email"
|
|
96
|
+
USER_ORG_ID = "user.org_id"
|
|
97
|
+
USER_ROLES = "user.roles"
|
|
98
|
+
# response.*
|
|
99
|
+
RESPONSE_STATUS = "response.status"
|
|
100
|
+
RESPONSE_DURATION_MS = "response.duration_ms"
|
|
101
|
+
RESPONSE_OUTPUT_JSON = "response.output_json"
|
|
102
|
+
# route.*
|
|
103
|
+
ROUTE_NAME = "route.name"
|
|
104
|
+
ROUTE_PATH = "route.path"
|
|
105
|
+
# error.*
|
|
106
|
+
ERROR_TYPE = "error.type"
|
|
107
|
+
ERROR_MESSAGE = "error.message"
|
|
108
|
+
ERROR_STACK = "error.stack"
|
|
109
|
+
# trace.*
|
|
110
|
+
TRACE_ID = "trace.id"
|
|
111
|
+
TRACE_SPAN_ID = "trace.span_id"
|
|
112
|
+
# routing + build context
|
|
113
|
+
TARGET = "target"
|
|
114
|
+
GIT_COMMIT = "git.commit"
|
|
115
|
+
GIT_BRANCH = "git.branch"
|
|
116
|
+
GIT_TAG = "git.tag"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# The canonical registry. Keyed by dotted name; the key must equal ``Field.key``.
|
|
120
|
+
FIELDS: Dict[str, Field] = {
|
|
121
|
+
"request.id": Field("request.id", "keyword"),
|
|
122
|
+
"request.method": Field("request.method", "keyword"),
|
|
123
|
+
"request.path": Field("request.path", "keyword", aliases=("request.url",)),
|
|
124
|
+
"request.ip": Field("request.ip", "keyword"),
|
|
125
|
+
# Opaque, retrieval-only: unbounded caller-controlled body, redacted then
|
|
126
|
+
# serialised to one string so it cannot explode the index field count.
|
|
127
|
+
"request.body_json": Field("request.body_json", "keyword", indexed=False, sensitivity="redact"),
|
|
128
|
+
"user.id": Field("user.id", "keyword"),
|
|
129
|
+
"user.email": Field("user.email", "keyword", sensitivity="pii", aliases=("request.user",)),
|
|
130
|
+
# Tenant. company.* folds in here (a distinct company name, if ever needed,
|
|
131
|
+
# is a consumer domain field).
|
|
132
|
+
"user.org_id": Field("user.org_id", "keyword", aliases=("request.company", "request.company_id")),
|
|
133
|
+
"user.roles": Field("user.roles", "keyword"), # array; ES arrays are implicit
|
|
134
|
+
"response.status": Field("response.status", "long"),
|
|
135
|
+
"response.duration_ms": Field(
|
|
136
|
+
"response.duration_ms",
|
|
137
|
+
"long",
|
|
138
|
+
aliases=("response.duration", "api.duration_ms", "duration.ms"),
|
|
139
|
+
),
|
|
140
|
+
# The response twin of request.body_json (unbounded response payloads).
|
|
141
|
+
"response.output_json": Field("response.output_json", "keyword", indexed=False, sensitivity="redact"),
|
|
142
|
+
"route.name": Field("route.name", "keyword"),
|
|
143
|
+
"route.path": Field("route.path", "keyword"),
|
|
144
|
+
"error.type": Field("error.type", "keyword"),
|
|
145
|
+
"error.message": Field("error.message", "text"),
|
|
146
|
+
"error.stack": Field("error.stack", "text"),
|
|
147
|
+
"trace.id": Field("trace.id", "keyword", aliases=("request.trace_id",)),
|
|
148
|
+
"trace.span_id": Field("trace.span_id", "keyword"),
|
|
149
|
+
# ES index routing. Constrain to known values at the consumer (misroute risk).
|
|
150
|
+
"target": Field("target", "keyword"),
|
|
151
|
+
# Optional build context; consumer-bound via bind_build_context, not automatic.
|
|
152
|
+
"git.commit": Field("git.commit", "keyword"),
|
|
153
|
+
"git.branch": Field("git.branch", "keyword"),
|
|
154
|
+
"git.tag": Field("git.tag", "keyword"),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _key_errors(key: object) -> List[str]:
|
|
159
|
+
"""Return a list of naming problems for ``key`` (empty means valid).
|
|
160
|
+
|
|
161
|
+
A valid key is one or more ``.``-joined snake_case segments, each starting
|
|
162
|
+
with a lowercase letter: ``target``, ``request.method``, ``user.org_id``.
|
|
163
|
+
Rejects camelCase, hyphens, leading digits, and empty segments.
|
|
164
|
+
"""
|
|
165
|
+
if not isinstance(key, str) or not key:
|
|
166
|
+
return [f"{key!r}: not a non-empty string"]
|
|
167
|
+
errors: List[str] = []
|
|
168
|
+
for seg in key.split("."):
|
|
169
|
+
if not _SEGMENT_RE.match(seg):
|
|
170
|
+
errors.append(f"{key!r}: segment {seg!r} is not lowercase snake_case")
|
|
171
|
+
return errors
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def validate_registry() -> List[str]:
|
|
175
|
+
"""Validate the core :data:`FIELDS` registry, returning a list of errors.
|
|
176
|
+
|
|
177
|
+
Checks that every key matches its ``Field.key``, is dotted snake_case, has a
|
|
178
|
+
known type and sensitivity, and that aliases are globally unique and do not
|
|
179
|
+
collide with any canonical key. Also asserts every :class:`F` constant is a
|
|
180
|
+
registered field. An empty list means the registry is internally consistent.
|
|
181
|
+
"""
|
|
182
|
+
errors: List[str] = []
|
|
183
|
+
seen_aliases: Dict[str, str] = {}
|
|
184
|
+
for key, fld in FIELDS.items():
|
|
185
|
+
if fld.key != key:
|
|
186
|
+
errors.append(f"{key!r}: Field.key {fld.key!r} does not match registry key")
|
|
187
|
+
errors.extend(_key_errors(key))
|
|
188
|
+
if fld.type not in _KNOWN_TYPES:
|
|
189
|
+
errors.append(f"{key!r}: unknown type {fld.type!r}")
|
|
190
|
+
if fld.sensitivity not in _SENSITIVITIES:
|
|
191
|
+
errors.append(f"{key!r}: unknown sensitivity {fld.sensitivity!r}")
|
|
192
|
+
for alias in fld.aliases:
|
|
193
|
+
errors.extend(_key_errors(alias))
|
|
194
|
+
if alias in FIELDS:
|
|
195
|
+
errors.append(f"{key!r}: alias {alias!r} collides with a canonical field key")
|
|
196
|
+
if alias in seen_aliases:
|
|
197
|
+
errors.append(f"alias {alias!r} used by both {seen_aliases[alias]!r} and {key!r}")
|
|
198
|
+
else:
|
|
199
|
+
seen_aliases[alias] = key
|
|
200
|
+
# Every F constant must be a real field.
|
|
201
|
+
for name in dir(F):
|
|
202
|
+
if name.startswith("_"):
|
|
203
|
+
continue
|
|
204
|
+
value = getattr(F, name)
|
|
205
|
+
if isinstance(value, str) and value not in FIELDS:
|
|
206
|
+
errors.append(f"F.{name} = {value!r} is not in FIELDS")
|
|
207
|
+
return errors
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def validate_fields(*modules: object) -> List[str]:
|
|
211
|
+
"""Validate that a consumer's field constants are dotted snake_case names.
|
|
212
|
+
|
|
213
|
+
Pass one or more classes / modules whose public (non-underscore) string
|
|
214
|
+
attributes are log field names. Returns a list of naming errors (empty means
|
|
215
|
+
all valid), so a consumer's CI can assert ``validate_fields(OrderFields) == []``
|
|
216
|
+
and catch drift from the shared convention.
|
|
217
|
+
"""
|
|
218
|
+
errors: List[str] = []
|
|
219
|
+
for mod in modules:
|
|
220
|
+
label = getattr(mod, "__name__", type(mod).__name__)
|
|
221
|
+
for name in dir(mod):
|
|
222
|
+
if name.startswith("_"):
|
|
223
|
+
continue
|
|
224
|
+
value = getattr(mod, name)
|
|
225
|
+
if not isinstance(value, str):
|
|
226
|
+
continue
|
|
227
|
+
errors.extend(f"{label}.{name}: {err}" for err in _key_errors(value))
|
|
228
|
+
return errors
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def redaction_keys() -> frozenset:
|
|
232
|
+
"""Return the leaf names of ``pii`` / ``redact`` fields for the deny set.
|
|
233
|
+
|
|
234
|
+
These extend the redactor's base pattern list so that any payload key with a
|
|
235
|
+
sensitive name (e.g. ``email`` from ``user.email``) is masked by default.
|
|
236
|
+
"""
|
|
237
|
+
return frozenset(fld.key.split(".")[-1] for fld in FIELDS.values() if fld.sensitivity in ("pii", "redact"))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def alias_map() -> Dict[str, str]:
|
|
241
|
+
"""Return the ``old_name -> canonical_key`` migration map from all aliases."""
|
|
242
|
+
mapping: Dict[str, str] = {}
|
|
243
|
+
for fld in FIELDS.values():
|
|
244
|
+
for alias in fld.aliases:
|
|
245
|
+
mapping[alias] = fld.key
|
|
246
|
+
return mapping
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Guard the registry at import: a malformed core registry is a bug that should
|
|
250
|
+
# surface the moment the logging package is first used, not silently at query time.
|
|
251
|
+
_REGISTRY_ERRORS = validate_registry()
|
|
252
|
+
if _REGISTRY_ERRORS:
|
|
253
|
+
raise ValueError("Invalid logging field registry:\n " + "\n ".join(_REGISTRY_ERRORS))
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Recursive redaction for opaque log payloads (deny-list default, allow-list opt-in).
|
|
2
|
+
|
|
3
|
+
Bodies and response payloads carry unbounded, caller-controlled keys, so they are
|
|
4
|
+
scrubbed before being serialised into ``request.body_json`` / ``response.output_json``.
|
|
5
|
+
Two strategies, layered:
|
|
6
|
+
|
|
7
|
+
- **Deny-list (default)**: a base set of sensitive key-name patterns (password,
|
|
8
|
+
token, secret, authorization, api_key, cookie, ...) plus the ``pii`` / ``redact``
|
|
9
|
+
leaf names from the field registry, matched recursively and masked. Suitable for
|
|
10
|
+
general debug payloads where most keys are legitimate and only known-sensitive
|
|
11
|
+
ones must go.
|
|
12
|
+
- **Allow-list (opt-in via ``allow_only``)**: only the named keys survive, every
|
|
13
|
+
other key is masked. PII-heavy surfaces (auth, profile, anything logging headers
|
|
14
|
+
or authorization) should pass ``allow_only`` so the payload is a known safe
|
|
15
|
+
subset rather than "everything minus the deny-list".
|
|
16
|
+
|
|
17
|
+
The redactor never mutates the caller's structure: it rebuilds the dict / list
|
|
18
|
+
spine and shares only leaf scalars (which are treated as immutable and are
|
|
19
|
+
serialised with ``json.dumps(default=str)`` downstream).
|
|
20
|
+
|
|
21
|
+
Documented v1 gaps (not silently accepted, tracked for v2):
|
|
22
|
+
|
|
23
|
+
- **Value-embedded secrets**: a bearer token or JWT inside a URL string, or a
|
|
24
|
+
secret in a free-text value, is not caught (only key *names* are matched).
|
|
25
|
+
- **Leaky custom objects**: an object whose ``__str__`` / ``__repr__`` exposes a
|
|
26
|
+
secret is serialised as-is.
|
|
27
|
+
|
|
28
|
+
Value-pattern masking (matching secret-shaped *values*, not just key names) is v2.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import re
|
|
34
|
+
from typing import Any, Dict, Iterable, Optional
|
|
35
|
+
|
|
36
|
+
from .fields import redaction_keys
|
|
37
|
+
|
|
38
|
+
#: Mask substituted for redacted values.
|
|
39
|
+
MASK = "***REDACTED***"
|
|
40
|
+
|
|
41
|
+
#: Base deny-list of sensitive key-name patterns. Deny matching is by whole
|
|
42
|
+
#: snake_case *word* (or phrase), so header variants like ``X-Api-Key`` and
|
|
43
|
+
#: camelCase like ``accessToken`` match, while ``mapping`` / ``author`` /
|
|
44
|
+
#: ``wildcard`` do NOT falsely match ``pin`` / ``auth`` / ``card``. Extend per
|
|
45
|
+
#: call via ``deny_extra`` rather than editing this set.
|
|
46
|
+
_BASE_DENY = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"password",
|
|
49
|
+
"passwd",
|
|
50
|
+
"pwd",
|
|
51
|
+
"secret",
|
|
52
|
+
"token",
|
|
53
|
+
"access_token",
|
|
54
|
+
"refresh_token",
|
|
55
|
+
"id_token",
|
|
56
|
+
"authorization",
|
|
57
|
+
"auth",
|
|
58
|
+
"jwt",
|
|
59
|
+
"bearer",
|
|
60
|
+
"api_key",
|
|
61
|
+
"apikey",
|
|
62
|
+
"access_key",
|
|
63
|
+
"secret_key",
|
|
64
|
+
"private_key",
|
|
65
|
+
"session_id",
|
|
66
|
+
"cookie",
|
|
67
|
+
"set_cookie",
|
|
68
|
+
"headers",
|
|
69
|
+
"credentials",
|
|
70
|
+
"card_number",
|
|
71
|
+
"cvv",
|
|
72
|
+
"cvc",
|
|
73
|
+
"ssn",
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
#: High-signal secret tokens also matched as a plain substring of the
|
|
78
|
+
#: separator-stripped key, so all-lowercase concatenations without a delimiter
|
|
79
|
+
#: (``accesstoken``, ``apitoken``, ``privatekey``) are caught too. Kept to long,
|
|
80
|
+
#: unambiguous words to avoid masking innocent keys (``key`` is deliberately NOT
|
|
81
|
+
#: here: it would mask ``monkey`` / ``primary_key``).
|
|
82
|
+
_SUBSTRING_DENY = frozenset(
|
|
83
|
+
{
|
|
84
|
+
"password",
|
|
85
|
+
"passwd",
|
|
86
|
+
"secret",
|
|
87
|
+
"token",
|
|
88
|
+
"authorization",
|
|
89
|
+
"apikey",
|
|
90
|
+
"credential",
|
|
91
|
+
"privatekey",
|
|
92
|
+
"accesskey",
|
|
93
|
+
"secretkey",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
_NON_ALNUM = re.compile(r"[^a-z0-9]")
|
|
98
|
+
_CAMEL_1 = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
|
|
99
|
+
_CAMEL_2 = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z])")
|
|
100
|
+
_SEP = re.compile(r"[-.\s]+")
|
|
101
|
+
_MULTI_US = re.compile(r"_+")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _to_snake(name: object) -> str:
|
|
105
|
+
"""Normalise a key to lowercase snake_case (camelCase and ``-`` / ``.`` unified).
|
|
106
|
+
|
|
107
|
+
``apiKey`` / ``X-Api-Key`` / ``api.key`` all normalise to ``api_key``, so the
|
|
108
|
+
matcher is agnostic to the caller's naming style.
|
|
109
|
+
"""
|
|
110
|
+
text = str(name)
|
|
111
|
+
text = _CAMEL_1.sub("_", text)
|
|
112
|
+
text = _CAMEL_2.sub("_", text)
|
|
113
|
+
text = _SEP.sub("_", text.lower())
|
|
114
|
+
return _MULTI_US.sub("_", text).strip("_")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _in_deny(snake_key: str, deny: frozenset) -> bool:
|
|
118
|
+
"""True if ``snake_key`` matches a deny phrase.
|
|
119
|
+
|
|
120
|
+
Two layers:
|
|
121
|
+
|
|
122
|
+
- Whole-word / phrase match on ``_``-delimited words (avoids short-token false
|
|
123
|
+
positives: ``card`` matches ``card_number`` and ``card`` but not ``wildcard``).
|
|
124
|
+
- Plain substring match for the curated high-signal tokens in
|
|
125
|
+
:data:`_SUBSTRING_DENY`, over the separator-stripped key, so all-lowercase
|
|
126
|
+
concatenations (``accesstoken``, ``apitoken``) are caught too.
|
|
127
|
+
"""
|
|
128
|
+
for phrase in deny:
|
|
129
|
+
if (
|
|
130
|
+
snake_key == phrase
|
|
131
|
+
or snake_key.startswith(phrase + "_")
|
|
132
|
+
or snake_key.endswith("_" + phrase)
|
|
133
|
+
or ("_" + phrase + "_") in snake_key
|
|
134
|
+
):
|
|
135
|
+
return True
|
|
136
|
+
collapsed = _NON_ALNUM.sub("", snake_key)
|
|
137
|
+
return any(token in collapsed for token in _SUBSTRING_DENY)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _build_deny(deny_extra: Optional[Iterable[str]]) -> frozenset:
|
|
141
|
+
keys = set(_BASE_DENY) | set(redaction_keys())
|
|
142
|
+
if deny_extra:
|
|
143
|
+
keys |= set(deny_extra)
|
|
144
|
+
return frozenset(_to_snake(k) for k in keys)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _redact(value: object, deny: frozenset, allow: Optional[frozenset]) -> object:
|
|
148
|
+
if isinstance(value, dict):
|
|
149
|
+
out: Dict[Any, Any] = {}
|
|
150
|
+
for key, val in value.items():
|
|
151
|
+
snake_key = _to_snake(key)
|
|
152
|
+
if _in_deny(snake_key, deny):
|
|
153
|
+
out[key] = MASK
|
|
154
|
+
elif allow is not None and snake_key not in allow:
|
|
155
|
+
out[key] = MASK
|
|
156
|
+
else:
|
|
157
|
+
out[key] = _redact(val, deny, allow)
|
|
158
|
+
return out
|
|
159
|
+
if isinstance(value, (list, tuple)):
|
|
160
|
+
return [_redact(item, deny, allow) for item in value]
|
|
161
|
+
return value
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def default_redactor(
|
|
165
|
+
payload: object,
|
|
166
|
+
*,
|
|
167
|
+
allow_only: Optional[Iterable[str]] = None,
|
|
168
|
+
deny_extra: Optional[Iterable[str]] = None,
|
|
169
|
+
) -> object:
|
|
170
|
+
"""Return a redacted copy of ``payload`` without mutating the original.
|
|
171
|
+
|
|
172
|
+
Descends dicts and lists. In the default deny-list mode, keys whose name
|
|
173
|
+
matches the base pattern set or a registry-derived sensitive leaf (by whole
|
|
174
|
+
snake_case word) are masked. Passing ``allow_only`` switches to allow-list
|
|
175
|
+
mode: only keys whose normalised name exactly equals a named key survive at any
|
|
176
|
+
depth. The deny-list still applies as a backstop, so a key that is both allowed
|
|
177
|
+
and sensitive is still masked (identity fields belong in ``bind_user``, not in a
|
|
178
|
+
redacted body). Non-container payloads (str, int, ``None``, ...) pass through
|
|
179
|
+
unchanged.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
payload: The structure to redact (typically a request / response body).
|
|
183
|
+
allow_only: If given, keep only these key names (exact, normalised); mask
|
|
184
|
+
the rest. Use on PII-heavy routes.
|
|
185
|
+
deny_extra: Extra sensitive key names to add to the deny-list for this call.
|
|
186
|
+
"""
|
|
187
|
+
deny = _build_deny(deny_extra)
|
|
188
|
+
allow = frozenset(_to_snake(k) for k in allow_only) if allow_only is not None else None
|
|
189
|
+
return _redact(payload, deny, allow)
|
nui_shared_utils/slack_client.py
CHANGED
|
@@ -388,7 +388,9 @@ class SlackClient(BaseClient, ServiceHealthMixin):
|
|
|
388
388
|
blocks: Optional[List[Dict]] = None,
|
|
389
389
|
attachments: Optional[List[Dict]] = None,
|
|
390
390
|
include_lambda_header: bool = True,
|
|
391
|
-
event_type: Optional[str] = None
|
|
391
|
+
event_type: Optional[str] = None,
|
|
392
|
+
unfurl_links: Optional[bool] = None,
|
|
393
|
+
unfurl_media: Optional[bool] = None,
|
|
392
394
|
) -> bool:
|
|
393
395
|
"""
|
|
394
396
|
Send message to Slack channel with standardized error handling.
|
|
@@ -400,6 +402,10 @@ class SlackClient(BaseClient, ServiceHealthMixin):
|
|
|
400
402
|
attachments: Legacy attachment objects (supports color sidebars)
|
|
401
403
|
include_lambda_header: Whether to include context header
|
|
402
404
|
event_type: Optional event type label for header (e.g., "Scheduled", "API", "SQS")
|
|
405
|
+
unfurl_links: Override Slack link unfurling. None leaves Slack's default;
|
|
406
|
+
False suppresses link preview cards (links stay clickable)
|
|
407
|
+
unfurl_media: Override Slack media unfurling. None leaves Slack's default;
|
|
408
|
+
False suppresses media preview cards
|
|
403
409
|
|
|
404
410
|
Returns:
|
|
405
411
|
True if successful, False otherwise
|
|
@@ -419,12 +425,19 @@ class SlackClient(BaseClient, ServiceHealthMixin):
|
|
|
419
425
|
else:
|
|
420
426
|
blocks_with_header = blocks
|
|
421
427
|
|
|
422
|
-
|
|
428
|
+
kwargs = dict(
|
|
423
429
|
channel=channel,
|
|
424
430
|
text=text,
|
|
425
431
|
blocks=blocks_with_header,
|
|
426
|
-
attachments=attachments
|
|
432
|
+
attachments=attachments,
|
|
427
433
|
)
|
|
434
|
+
# Forward unfurl controls only when explicitly set, so existing callers
|
|
435
|
+
# keep Slack's default behavior (and their call assertions stay unchanged).
|
|
436
|
+
if unfurl_links is not None:
|
|
437
|
+
kwargs["unfurl_links"] = unfurl_links
|
|
438
|
+
if unfurl_media is not None:
|
|
439
|
+
kwargs["unfurl_media"] = unfurl_media
|
|
440
|
+
response = self._service_client.chat_postMessage(**kwargs)
|
|
428
441
|
|
|
429
442
|
if response["ok"]:
|
|
430
443
|
log.info(
|
{nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{nui_python_shared_utils-1.4.1.dist-info → nui_python_shared_utils-1.6.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|