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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nui-python-shared-utils
3
- Version: 1.4.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.4.1.dist-info/licenses/LICENSE,sha256=vGe2mC5yLUb8toYlY3T36ZwCB5zQUW5hlCtEMiqokhM,1071
25
- nui_shared_utils/__init__.py,sha256=WOpBwxBqJK_wq8l3s5TCCs4j__lMTbI7TjraTXfmH4c,11851
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=_qR7Q1GU7gvYhUxiaBxEohhoEYznqr8kz9enGfeDtn8,24759
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.4.1.dist-info/METADATA,sha256=jYHBmx1R9T3PEGS2aLOY0DvcuZFm7DuebJL4VaV3z_c,22632
50
- nui_python_shared_utils-1.4.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
51
- nui_python_shared_utils-1.4.1.dist-info/entry_points.txt,sha256=TrJ3Z4kz3oXfy6InXn20jHu7rnTwo-4EA0KRFX3fkL0,66
52
- nui_python_shared_utils-1.4.1.dist-info/top_level.txt,sha256=sNceq5okmEB54L-gm4a0OgGhnPlU62zYmlUoXKn-Fa8,41
53
- nui_python_shared_utils-1.4.1.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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)
@@ -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
- response = self._service_client.chat_postMessage(
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(