nui-lambda-shared-utils 1.1.4__tar.gz → 1.1.5__tar.gz

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.
Files changed (68) hide show
  1. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/PKG-INFO +1 -1
  2. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/README.md +1 -0
  3. nui_lambda_shared_utils-1.1.5/docs/guides/log-processing.md +156 -0
  4. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/__init__.py +13 -0
  5. nui_lambda_shared_utils-1.1.5/nui_lambda_shared_utils/log_processors.py +172 -0
  6. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/powertools_helpers.py +5 -1
  7. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_client.py +45 -28
  8. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils.egg-info/SOURCES.txt +3 -0
  9. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_lambda_helpers.py +6 -0
  10. nui_lambda_shared_utils-1.1.5/tests/test_log_processors.py +236 -0
  11. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_slack_client.py +127 -105
  12. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.editorconfig +0 -0
  13. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.github/workflows/ci.yml +0 -0
  14. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.github/workflows/publish.yml +0 -0
  15. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.github/workflows/test.yml +0 -0
  16. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.markdownlint-cli2.yaml +0 -0
  17. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/CLAUDE.md +0 -0
  18. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/CONTRIBUTING.md +0 -0
  19. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/LICENSE +0 -0
  20. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/MANIFEST.in +0 -0
  21. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/README.md +0 -0
  22. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/development/testing.md +0 -0
  23. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/getting-started/configuration.md +0 -0
  24. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/getting-started/installation.md +0 -0
  25. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/getting-started/quickstart.md +0 -0
  26. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/cli-tools.md +0 -0
  27. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/elasticsearch-integration.md +0 -0
  28. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/lambda-utilities.md +0 -0
  29. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/powertools-integration.md +0 -0
  30. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/shared-types.md +0 -0
  31. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/slack-integration.md +0 -0
  32. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/mypy.ini +0 -0
  33. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/base_client.py +0 -0
  34. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/cli.py +0 -0
  35. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/cloudwatch_metrics.py +0 -0
  36. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/config.py +0 -0
  37. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/db_client.py +0 -0
  38. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/error_handler.py +0 -0
  39. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/es_client.py +0 -0
  40. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/es_query_builder.py +0 -0
  41. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/lambda_helpers.py +0 -0
  42. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/secrets_helper.py +0 -0
  43. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_formatter.py +0 -0
  44. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_setup/__init__.py +0 -0
  45. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_setup/channel_creator.py +0 -0
  46. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_setup/channel_definitions.py +0 -0
  47. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_setup/setup_helpers.py +0 -0
  48. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/timezone.py +0 -0
  49. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/utils.py +0 -0
  50. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/pyproject.toml +0 -0
  51. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/pytest.ini +0 -0
  52. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/requirements-test.txt +0 -0
  53. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/setup.cfg +0 -0
  54. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/setup.py +0 -0
  55. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/__init__.py +0 -0
  56. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_aws_utils.py +0 -0
  57. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_base_client.py +0 -0
  58. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_cloudwatch_metrics.py +0 -0
  59. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_config.py +0 -0
  60. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_db_client.py +0 -0
  61. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_error_handler.py +0 -0
  62. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_es_client.py +0 -0
  63. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_es_query_builder.py +0 -0
  64. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_powertools_helpers.py +0 -0
  65. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_secrets_helper.py +0 -0
  66. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_slack_formatter.py +0 -0
  67. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_timezone.py +0 -0
  68. {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nui-lambda-shared-utils
3
- Version: 1.1.4
3
+ Version: 1.1.5
4
4
  Summary: Enterprise-grade utilities for AWS Lambda functions with Slack, Elasticsearch, and monitoring integrations
5
5
  Home-page: https://github.com/nuimarkets/nui-lambda-shared-utils
6
6
  Author: NUI Markets
@@ -20,6 +20,7 @@ Component-specific guides for major features:
20
20
  - **[Lambda Context Helpers](guides/lambda-utilities.md)** - Environment info extraction for logging and metrics
21
21
  - **[Slack Integration](guides/slack-integration.md)** - Messaging, formatting, and file uploads
22
22
  - **[Elasticsearch Integration](guides/elasticsearch-integration.md)** - Search, bulk indexing, health checks
23
+ - **[Log Processing](guides/log-processing.md)** - Kinesis log extraction and ES index naming
23
24
  - Database Connections (planned)
24
25
  - Error Handling Patterns (planned)
25
26
  - CloudWatch Metrics (planned)
@@ -0,0 +1,156 @@
1
+ # Log Processing Guide
2
+
3
+ This guide covers the log processing utilities for Lambda functions that stream
4
+ CloudWatch logs to Elasticsearch via Kinesis.
5
+
6
+ ## Overview
7
+
8
+ The `log_processors` module provides utilities for:
9
+
10
+ - Extracting CloudWatch logs from Kinesis stream records
11
+ - Deriving Elasticsearch index names from log metadata
12
+ - Type definitions for CloudWatch log structures
13
+
14
+ ## Quick Start
15
+
16
+ ### Basic Usage
17
+
18
+ ```python
19
+ from elasticsearch.helpers import streaming_bulk
20
+ from nui_lambda_shared_utils.log_processors import (
21
+ extract_cloudwatch_logs_from_kinesis,
22
+ derive_index_name,
23
+ )
24
+ from datetime import datetime, timezone
25
+
26
+
27
+ def process_log_events(log_group: str, log_stream: str, log_events: list):
28
+ """Process log events and yield ES documents."""
29
+ for event in log_events:
30
+ ts = datetime.fromtimestamp(event["timestamp"] / 1000.0, tz=timezone.utc)
31
+
32
+ yield {
33
+ "_index": derive_index_name(log_group, ts),
34
+ "_id": event["id"],
35
+ "_source": {
36
+ "message": event["message"],
37
+ "@timestamp": ts.isoformat(),
38
+ "log": {"group": log_group, "stream": log_stream},
39
+ }
40
+ }
41
+
42
+
43
+ def handler(event, context):
44
+ """Lambda handler for Kinesis -> ES streaming."""
45
+ es = get_elasticsearch_client()
46
+
47
+ for ok, response in streaming_bulk(
48
+ client=es,
49
+ actions=extract_cloudwatch_logs_from_kinesis(
50
+ event["Records"],
51
+ process_fn=process_log_events
52
+ ),
53
+ chunk_size=100,
54
+ raise_on_error=True,
55
+ ):
56
+ if not ok:
57
+ logger.error(f"Document indexing failed: {response}")
58
+ ```
59
+
60
+ ### Error Handling
61
+
62
+ Provide an `on_error` callback to handle failures without stopping the entire batch:
63
+
64
+ ```python
65
+ def handle_processing_error(exception: Exception, record_data: dict):
66
+ """Log errors but continue processing."""
67
+ logger.error(f"Failed to process record: {exception}")
68
+ # Optionally send to dead letter queue, metrics, etc.
69
+
70
+
71
+ for doc in extract_cloudwatch_logs_from_kinesis(
72
+ event["Records"],
73
+ process_fn=process_log_events,
74
+ on_error=handle_processing_error
75
+ ):
76
+ # Documents from successfully processed records
77
+ pass
78
+ ```
79
+
80
+ ### Custom Index Naming
81
+
82
+ Override the default index naming pattern:
83
+
84
+ ```python
85
+ from nui_lambda_shared_utils.log_processors import derive_index_name
86
+
87
+ # Default: log-{service}-{YYYY}-m{MM}
88
+ derive_index_name("/aws/lambda/orders", ts)
89
+ # -> "log-orders-2025-m01"
90
+
91
+ # Custom target
92
+ derive_index_name("/aws/lambda/orders", ts, target_override="order-service")
93
+ # -> "log-order-service-2025-m01"
94
+
95
+ # Custom prefix and date format
96
+ derive_index_name("/aws/lambda/orders", ts, prefix="logs", date_format="%Y-%m-%d")
97
+ # -> "logs-orders-2025-01-15"
98
+ ```
99
+
100
+ ## Migration Guide
101
+
102
+ ### From inline Kinesis extraction
103
+
104
+ Replace:
105
+
106
+ ```python
107
+ # Before
108
+ def extract_logs(records):
109
+ for row in records:
110
+ raw_data = row["kinesis"]["data"]
111
+ data = json.loads(
112
+ zlib.decompress(base64.b64decode(raw_data), 16 + zlib.MAX_WBITS).decode("utf-8")
113
+ )
114
+ if data["messageType"] == "CONTROL_MESSAGE":
115
+ continue
116
+ for item in process_log_events(...):
117
+ yield item
118
+ ```
119
+
120
+ With:
121
+
122
+ ```python
123
+ # After
124
+ from nui_lambda_shared_utils.log_processors import extract_cloudwatch_logs_from_kinesis
125
+
126
+ for doc in extract_cloudwatch_logs_from_kinesis(event["Records"], process_log_events):
127
+ yield doc
128
+ ```
129
+
130
+ ## API Reference
131
+
132
+ ### `extract_cloudwatch_logs_from_kinesis()`
133
+
134
+ Extract CloudWatch logs from Kinesis stream records.
135
+
136
+ **Parameters:**
137
+
138
+ - `records`: List of Kinesis event records (`event["Records"]`)
139
+ - `process_fn`: Callback to process log events
140
+ - `on_error`: Optional error handler (if None, exceptions are raised)
141
+
142
+ **Yields:** Dict documents ready for `streaming_bulk()`
143
+
144
+ ### `derive_index_name()`
145
+
146
+ Derive Elasticsearch index name from log metadata.
147
+
148
+ **Parameters:**
149
+
150
+ - `log_group`: CloudWatch log group name
151
+ - `timestamp`: Event timestamp for date suffix
152
+ - `prefix`: Index name prefix (default: "log")
153
+ - `date_format`: strftime format (default: "%Y-m%m")
154
+ - `target_override`: Custom service name (optional)
155
+
156
+ **Returns:** Index name string
@@ -121,6 +121,14 @@ except ImportError:
121
121
  get_powertools_logger = None # type: ignore
122
122
  powertools_handler = None # type: ignore
123
123
 
124
+ # Log processing utilities (no external dependencies)
125
+ from .log_processors import (
126
+ CloudWatchLogEvent,
127
+ CloudWatchLogsData,
128
+ derive_index_name,
129
+ extract_cloudwatch_logs_from_kinesis,
130
+ )
131
+
124
132
  # Lambda context helpers (no external dependencies)
125
133
  from .lambda_helpers import get_lambda_environment_info
126
134
 
@@ -209,6 +217,11 @@ __all__ = [
209
217
  # AWS Powertools integration
210
218
  "get_powertools_logger",
211
219
  "powertools_handler",
220
+ # Log processing
221
+ "extract_cloudwatch_logs_from_kinesis",
222
+ "derive_index_name",
223
+ "CloudWatchLogEvent",
224
+ "CloudWatchLogsData",
212
225
  # Lambda context helpers
213
226
  "get_lambda_environment_info",
214
227
  ]
@@ -0,0 +1,172 @@
1
+ """
2
+ Utilities for extracting CloudWatch logs from Kinesis stream records.
3
+
4
+ Provides standardized Kinesis log extraction, decompression, and index naming
5
+ for Lambda functions that stream CloudWatch logs to Elasticsearch.
6
+ """
7
+
8
+ import base64
9
+ import json
10
+ import logging
11
+ import zlib
12
+ from datetime import datetime
13
+ from typing import Any, Callable, Dict, Iterator, List, Optional, TypedDict
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class CloudWatchLogEvent(TypedDict):
19
+ """Single log event from CloudWatch."""
20
+
21
+ id: str
22
+ timestamp: int # Unix timestamp in milliseconds
23
+ message: str
24
+
25
+
26
+ class CloudWatchLogsData(TypedDict):
27
+ """Decompressed CloudWatch logs data structure."""
28
+
29
+ messageType: str # "DATA_MESSAGE" or "CONTROL_MESSAGE"
30
+ owner: str
31
+ logGroup: str
32
+ logStream: str
33
+ subscriptionFilters: List[str]
34
+ logEvents: List[CloudWatchLogEvent]
35
+
36
+
37
+ def extract_cloudwatch_logs_from_kinesis(
38
+ records: List[Dict[str, Any]],
39
+ process_fn: Callable[[str, str, List[Dict]], Iterator[Dict]],
40
+ on_error: Optional[Callable[[Exception, Dict], None]] = None,
41
+ ) -> Iterator[Dict[str, Any]]:
42
+ """
43
+ Extract CloudWatch logs from Kinesis stream records.
44
+
45
+ Handles base64 decoding, gzip decompression, JSON parsing, and
46
+ CONTROL_MESSAGE filtering. Yields documents from the process_fn callback.
47
+
48
+ Args:
49
+ records: Kinesis event records (event["Records"])
50
+ process_fn: Callback to process log events. Signature:
51
+ process_fn(log_group: str, log_stream: str, log_events: List[Dict]) -> Iterator[Dict]
52
+ Should yield dicts with at minimum: {"_index": str, "_source": dict}
53
+ on_error: Optional error handler. If None, exceptions are raised.
54
+ Signature: on_error(exception: Exception, record_data: Dict) -> None
55
+
56
+ Yields:
57
+ Dict documents ready for Elasticsearch streaming_bulk()
58
+
59
+ Example:
60
+ from elasticsearch.helpers import streaming_bulk
61
+
62
+ def my_processor(log_group, log_stream, events):
63
+ for event in events:
64
+ yield {
65
+ "_index": f"log-{log_group.split('/')[-1]}-2025-01",
66
+ "_id": event["id"],
67
+ "_source": {"message": event["message"], ...}
68
+ }
69
+
70
+ for ok, response in streaming_bulk(
71
+ client=es,
72
+ actions=extract_cloudwatch_logs_from_kinesis(
73
+ event["Records"],
74
+ process_fn=my_processor
75
+ )
76
+ ):
77
+ if not ok:
78
+ logger.error(f"Failed: {response}")
79
+ """
80
+ log_counts = []
81
+
82
+ for row in records:
83
+ try:
84
+ raw_data = row["kinesis"]["data"]
85
+ except (KeyError, TypeError) as e:
86
+ logger.exception("Kinesis record missing 'kinesis.data' key")
87
+ if on_error:
88
+ on_error(e, {"row": row})
89
+ continue
90
+ raise
91
+
92
+ try:
93
+ decompressed = zlib.decompress(
94
+ base64.b64decode(raw_data), 16 + zlib.MAX_WBITS
95
+ ).decode("utf-8")
96
+ data = json.loads(decompressed)
97
+ except Exception as e:
98
+ logger.exception("Failed to decode/decompress Kinesis record")
99
+ if on_error:
100
+ on_error(e, {"raw_data": raw_data[:100]})
101
+ continue
102
+ raise
103
+
104
+ try:
105
+ message_type = data["messageType"]
106
+ log_group = data["logGroup"]
107
+ log_stream = data["logStream"]
108
+ log_events = data["logEvents"]
109
+ except KeyError as e:
110
+ logger.exception("Malformed CloudWatch logs payload missing key: %s", e)
111
+ if on_error:
112
+ on_error(e, data)
113
+ continue
114
+ raise
115
+
116
+ if message_type == "CONTROL_MESSAGE":
117
+ logger.debug("Skipping CONTROL_MESSAGE")
118
+ continue
119
+
120
+ log_counts.append(len(log_events))
121
+
122
+ try:
123
+ yield from process_fn(log_group, log_stream, log_events)
124
+ except Exception as e:
125
+ logger.exception(f"Failed to process log events from {log_group}")
126
+ if on_error:
127
+ on_error(e, data)
128
+ continue
129
+ raise
130
+
131
+ logger.debug(
132
+ f"Processed {sum(log_counts)} log events from {len(records)} Kinesis records"
133
+ )
134
+
135
+
136
+ def derive_index_name(
137
+ log_group: str,
138
+ timestamp: datetime,
139
+ prefix: str = "log",
140
+ date_format: str = "%Y-m%m",
141
+ target_override: Optional[str] = None,
142
+ ) -> str:
143
+ """
144
+ Derive Elasticsearch index name from log group and timestamp.
145
+
146
+ Default pattern: log-{service}-{YYYY}-m{MM}
147
+
148
+ Args:
149
+ log_group: CloudWatch log group name (e.g., "/aws/lambda/my-function")
150
+ timestamp: Event timestamp for date-based index suffix
151
+ prefix: Index name prefix (default: "log")
152
+ date_format: strftime format for date suffix (default: "%Y-m%m")
153
+ target_override: If provided, use this as service name instead of deriving from log_group
154
+
155
+ Returns:
156
+ Index name string (e.g., "log-my-function-2025-m01")
157
+
158
+ Example:
159
+ >>> derive_index_name("/aws/lambda/order-processor", datetime(2025, 1, 15))
160
+ 'log-order-processor-2025-m01'
161
+
162
+ >>> derive_index_name("/ecs/my-service", datetime(2025, 1, 15), target_override="custom")
163
+ 'log-custom-2025-m01'
164
+ """
165
+ if target_override:
166
+ service = target_override
167
+ else:
168
+ service = log_group.split("/")[-1]
169
+
170
+ date_suffix = timestamp.strftime(date_format)
171
+
172
+ return f"{prefix}-{service}-{date_suffix}".lower()
@@ -194,11 +194,15 @@ def powertools_handler(
194
194
 
195
195
  @functools.wraps(func)
196
196
  def wrapper(event: dict, context: Any) -> dict:
197
+ # Populate SlackClient account info from Lambda context ARN
198
+ if slack_client:
199
+ slack_client.set_handler_context(context)
200
+
197
201
  try:
198
202
  # Apply logger context injection
199
203
  # Note: inject_lambda_context is added dynamically to logging.Logger (line 95)
200
204
  # and is native to Powertools Logger. Type checker can't verify this union.
201
- handler_with_logging = logger.inject_lambda_context(func) # type: ignore[attr-defined]
205
+ handler_with_logging = logger.inject_lambda_context(func) # type: ignore[union-attr, attr-defined]
202
206
 
203
207
  # Apply metrics if configured
204
208
  if metrics:
@@ -4,7 +4,7 @@ Refactored Slack client using BaseClient for DRY code patterns.
4
4
 
5
5
  import os
6
6
  import logging
7
- from typing import List, Dict, Optional
7
+ from typing import Any, List, Dict, Optional
8
8
  from pathlib import Path
9
9
  from slack_sdk import WebClient
10
10
  from slack_sdk.errors import SlackApiError
@@ -165,6 +165,30 @@ class SlackClient(BaseClient, ServiceHealthMixin):
165
165
 
166
166
  return mappings
167
167
 
168
+ def set_handler_context(self, context: Any) -> None:
169
+ """
170
+ Extract AWS account ID from the Lambda handler context object.
171
+
172
+ Call this from your handler with the Lambda context to populate
173
+ account info without an STS API call. The powertools_handler
174
+ decorator calls this automatically.
175
+
176
+ Args:
177
+ context: Lambda context object (second arg to handler)
178
+ """
179
+ arn = getattr(context, "invoked_function_arn", None)
180
+ if not arn:
181
+ return
182
+
183
+ # ARN format: arn:aws:lambda:REGION:ACCOUNT_ID:function:NAME
184
+ parts = arn.split(":")
185
+ if len(parts) >= 5:
186
+ account_id = parts[4]
187
+ self._lambda_context["aws_account_id"] = account_id
188
+ self._lambda_context["aws_account_arn"] = arn
189
+ self._lambda_context["aws_account_name"] = self._account_names.get(account_id, "Unknown")
190
+ log.debug(f"Account ID from Lambda ARN: {account_id}")
191
+
168
192
  def _get_default_config_prefix(self) -> str:
169
193
  """Return configuration prefix for Slack."""
170
194
  return "slack"
@@ -204,23 +228,12 @@ class SlackClient(BaseClient, ServiceHealthMixin):
204
228
  "execution_env": os.environ.get("AWS_EXECUTION_ENV", "Unknown"),
205
229
  }
206
230
 
207
- # Get AWS account info using utility
208
- try:
209
- sts_client = create_aws_client("sts")
210
- account_info = sts_client.get_caller_identity()
211
- context["aws_account_id"] = account_info.get("Account", "Unknown")
212
- context["aws_account_arn"] = account_info.get("Arn", "Unknown")
213
- context["aws_account_name"] = self._account_names.get(
214
- context["aws_account_id"],
215
- f"Unknown Account ({context['aws_account_id']})"
216
- )
217
- except Exception as e:
218
- log.debug(f"Could not fetch AWS account info: {e}")
219
- context.update({
220
- "aws_account_id": "Unknown",
221
- "aws_account_name": "Unknown",
222
- "aws_account_arn": "Unknown"
223
- })
231
+ # Account info populated later by set_handler_context() from Lambda ARN
232
+ context.update({
233
+ "aws_account_id": "Unknown",
234
+ "aws_account_name": "Unknown",
235
+ "aws_account_arn": "Unknown",
236
+ })
224
237
 
225
238
  # Get deployment info
226
239
  context["deploy_time"] = self._get_deployment_age()
@@ -290,9 +303,10 @@ class SlackClient(BaseClient, ServiceHealthMixin):
290
303
  Returns:
291
304
  List of Slack blocks for Lambda context
292
305
  """
293
- # Get account name from configured mappings or show as Unknown
306
+ # Build account display from pre-resolved name + ID
294
307
  account_id = self._lambda_context['aws_account_id']
295
- simple_account = self._account_names.get(account_id, f"Unknown ({account_id})")
308
+ account_name = self._lambda_context['aws_account_name']
309
+ account_display = f"{account_name} ({account_id})" if account_id != "Unknown" else "Unknown"
296
310
 
297
311
  # Use custom service name if provided, otherwise use full function name
298
312
  display_name = self._service_name or self._lambda_context['function_name']
@@ -301,13 +315,14 @@ class SlackClient(BaseClient, ServiceHealthMixin):
301
315
  line1 = f"🤖 {display_name}"
302
316
  if event_type:
303
317
  line1 += f" • {event_type}"
304
- line2 = f"{simple_account} ({account_id}) • {self._lambda_context['aws_region']} • 📋 Log: `{self._lambda_context['log_group']}`"
318
+ line2 = f"{account_display} • {self._lambda_context['aws_region']}"
319
+ line3 = f"📋 Log: `{self._lambda_context['log_group']}`"
305
320
 
306
321
  return [{
307
322
  "type": "context",
308
323
  "elements": [{
309
324
  "type": "mrkdwn",
310
- "text": f"{line1}\n{line2}"
325
+ "text": f"{line1}\n{line2}\n{line3}"
311
326
  }]
312
327
  }]
313
328
 
@@ -330,15 +345,14 @@ class SlackClient(BaseClient, ServiceHealthMixin):
330
345
  username = "Unknown"
331
346
 
332
347
  timestamp = datetime.now(timezone.utc).strftime("%H:%M UTC")
333
- account_name = self._account_names.get(
334
- self._lambda_context["aws_account_id"],
335
- f"Unknown ({self._lambda_context['aws_account_id']})"
336
- )
348
+ account_id = self._lambda_context["aws_account_id"]
349
+ account_name = self._lambda_context["aws_account_name"]
350
+ account_display = f"{account_name} ({account_id})" if account_id != "Unknown" else "Unknown"
337
351
 
338
352
  line1 = f"👤 `Local Testing` • {username}"
339
353
  if event_type:
340
354
  line1 += f" • {event_type}"
341
- line2 = f"📍 {account_name} • {self._lambda_context['aws_region']} • {timestamp}"
355
+ line2 = f"📍 {account_display} • {self._lambda_context['aws_region']} • {timestamp}"
342
356
  line3 = "📋 Context: Manual/Development Testing"
343
357
 
344
358
  return [{
@@ -355,6 +369,7 @@ class SlackClient(BaseClient, ServiceHealthMixin):
355
369
  channel: str,
356
370
  text: str,
357
371
  blocks: Optional[List[Dict]] = None,
372
+ attachments: Optional[List[Dict]] = None,
358
373
  include_lambda_header: bool = True,
359
374
  event_type: Optional[str] = None
360
375
  ) -> bool:
@@ -365,6 +380,7 @@ class SlackClient(BaseClient, ServiceHealthMixin):
365
380
  channel: Channel ID
366
381
  text: Fallback text
367
382
  blocks: Rich formatted blocks
383
+ attachments: Legacy attachment objects (supports color sidebars)
368
384
  include_lambda_header: Whether to include context header
369
385
  event_type: Optional event type label for header (e.g., "Scheduled", "API", "SQS")
370
386
 
@@ -389,7 +405,8 @@ class SlackClient(BaseClient, ServiceHealthMixin):
389
405
  response = self._service_client.chat_postMessage(
390
406
  channel=channel,
391
407
  text=text,
392
- blocks=blocks_with_header
408
+ blocks=blocks_with_header,
409
+ attachments=attachments
393
410
  )
394
411
 
395
412
  if response["ok"]:
@@ -21,6 +21,7 @@ docs/getting-started/quickstart.md
21
21
  docs/guides/cli-tools.md
22
22
  docs/guides/elasticsearch-integration.md
23
23
  docs/guides/lambda-utilities.md
24
+ docs/guides/log-processing.md
24
25
  docs/guides/powertools-integration.md
25
26
  docs/guides/shared-types.md
26
27
  docs/guides/slack-integration.md
@@ -34,6 +35,7 @@ nui_lambda_shared_utils/error_handler.py
34
35
  nui_lambda_shared_utils/es_client.py
35
36
  nui_lambda_shared_utils/es_query_builder.py
36
37
  nui_lambda_shared_utils/lambda_helpers.py
38
+ nui_lambda_shared_utils/log_processors.py
37
39
  nui_lambda_shared_utils/powertools_helpers.py
38
40
  nui_lambda_shared_utils/secrets_helper.py
39
41
  nui_lambda_shared_utils/slack_client.py
@@ -54,6 +56,7 @@ tests/test_error_handler.py
54
56
  tests/test_es_client.py
55
57
  tests/test_es_query_builder.py
56
58
  tests/test_lambda_helpers.py
59
+ tests/test_log_processors.py
57
60
  tests/test_powertools_helpers.py
58
61
  tests/test_secrets_helper.py
59
62
  tests/test_slack_client.py
@@ -245,3 +245,9 @@ class TestExportedAPI:
245
245
  from nui_lambda_shared_utils.lambda_helpers import __all__
246
246
 
247
247
  assert "get_lambda_environment_info" in __all__
248
+
249
+ def test_importable_from_package_root(self):
250
+ """Test function is accessible from package root"""
251
+ from nui_lambda_shared_utils import get_lambda_environment_info
252
+
253
+ assert callable(get_lambda_environment_info)