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.
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/PKG-INFO +1 -1
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/README.md +1 -0
- nui_lambda_shared_utils-1.1.5/docs/guides/log-processing.md +156 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/__init__.py +13 -0
- nui_lambda_shared_utils-1.1.5/nui_lambda_shared_utils/log_processors.py +172 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/powertools_helpers.py +5 -1
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_client.py +45 -28
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils.egg-info/SOURCES.txt +3 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_lambda_helpers.py +6 -0
- nui_lambda_shared_utils-1.1.5/tests/test_log_processors.py +236 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_slack_client.py +127 -105
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.editorconfig +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.github/workflows/ci.yml +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.github/workflows/publish.yml +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.github/workflows/test.yml +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/.markdownlint-cli2.yaml +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/CLAUDE.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/CONTRIBUTING.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/LICENSE +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/MANIFEST.in +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/README.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/development/testing.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/getting-started/configuration.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/getting-started/installation.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/getting-started/quickstart.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/cli-tools.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/elasticsearch-integration.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/lambda-utilities.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/powertools-integration.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/shared-types.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/docs/guides/slack-integration.md +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/mypy.ini +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/base_client.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/cli.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/cloudwatch_metrics.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/config.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/db_client.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/error_handler.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/es_client.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/es_query_builder.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/lambda_helpers.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/secrets_helper.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_formatter.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/slack_setup/__init__.py +0 -0
- {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
- {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
- {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
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/timezone.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/utils.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/pyproject.toml +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/pytest.ini +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/requirements-test.txt +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/setup.cfg +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/setup.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/__init__.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_aws_utils.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_base_client.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_cloudwatch_metrics.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_config.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_db_client.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_error_handler.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_es_client.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_es_query_builder.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_powertools_helpers.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_secrets_helper.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_slack_formatter.py +0 -0
- {nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_timezone.py +0 -0
- {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.
|
|
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
|
{nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/nui_lambda_shared_utils/__init__.py
RENAMED
|
@@ -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
|
-
#
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
#
|
|
306
|
+
# Build account display from pre-resolved name + ID
|
|
294
307
|
account_id = self._lambda_context['aws_account_id']
|
|
295
|
-
|
|
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"{
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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"📍 {
|
|
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
|
{nui_lambda_shared_utils-1.1.4 → nui_lambda_shared_utils-1.1.5}/tests/test_lambda_helpers.py
RENAMED
|
@@ -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)
|