diff --git a/localstack-core/localstack/services/lambda_/invocation/logs.py b/localstack-core/localstack/services/lambda_/invocation/logs.py index a63f1ab2d04f4..2ff2ab35d951b 100644 --- a/localstack-core/localstack/services/lambda_/invocation/logs.py +++ b/localstack-core/localstack/services/lambda_/invocation/logs.py @@ -1,13 +1,13 @@ import dataclasses import logging import threading +import time from queue import Queue from typing import Optional, Union from localstack.aws.connect import connect_to from localstack.utils.aws.client_types import ServicePrincipal from localstack.utils.bootstrap import is_api_enabled -from localstack.utils.cloudwatch.cloudwatch_util import store_cloudwatch_logs from localstack.utils.threads import FuncThread LOG = logging.getLogger(__name__) @@ -50,10 +50,34 @@ def run_log_loop(self, *args, **kwargs) -> None: log_item = self.log_queue.get() if log_item is QUEUE_SHUTDOWN: return + # we need to split by newline - but keep the newlines in the strings + # strips empty lines, as they are not accepted by cloudwatch + logs = [line + "\n" for line in log_item.logs.split("\n") if line] + # until we have a better way to have timestamps, log events have the same time for a single invocation + log_events = [ + {"timestamp": int(time.time() * 1000), "message": log_line} for log_line in logs + ] try: - store_cloudwatch_logs( - logs_client, log_item.log_group, log_item.log_stream, log_item.logs - ) + try: + logs_client.put_log_events( + logGroupName=log_item.log_group, + logStreamName=log_item.log_stream, + logEvents=log_events, + ) + except logs_client.exceptions.ResourceNotFoundException: + # create new log group + try: + logs_client.create_log_group(logGroupName=log_item.log_group) + except logs_client.exceptions.ResourceAlreadyExistsException: + pass + logs_client.create_log_stream( + logGroupName=log_item.log_group, logStreamName=log_item.log_stream + ) + logs_client.put_log_events( + logGroupName=log_item.log_group, + logStreamName=log_item.log_stream, + logEvents=log_events, + ) except Exception as e: LOG.warning( "Error saving logs to group %s in region %s: %s", diff --git a/tests/aws/services/lambda_/functions/lambda_cloudwatch_logs.py b/tests/aws/services/lambda_/functions/lambda_cloudwatch_logs.py new file mode 100644 index 0000000000000..354749aa06122 --- /dev/null +++ b/tests/aws/services/lambda_/functions/lambda_cloudwatch_logs.py @@ -0,0 +1,10 @@ +""" +A simple handler which does a print on the "body" key of the event passed in. +Can be used to log different payloads, to check for the correct format in cloudwatch logs +""" + + +def handler(event, context): + # Just print the log line that was passed to lambda + print(event["body"]) + return event diff --git a/tests/aws/services/lambda_/test_lambda.py b/tests/aws/services/lambda_/test_lambda.py index ad50d73d258e7..700361d32ebbb 100644 --- a/tests/aws/services/lambda_/test_lambda.py +++ b/tests/aws/services/lambda_/test_lambda.py @@ -127,6 +127,7 @@ THIS_FOLDER, "functions/lambda_multiple_handlers.py" ) TEST_LAMBDA_NOTIFIER = os.path.join(THIS_FOLDER, "functions/lambda_notifier.py") +TEST_LAMBDA_CLOUDWATCH_LOGS = os.path.join(THIS_FOLDER, "functions/lambda_cloudwatch_logs.py") PYTHON_TEST_RUNTIMES = RUNTIMES_AGGREGATED["python"] NODE_TEST_RUNTIMES = RUNTIMES_AGGREGATED["nodejs"] diff --git a/tests/aws/services/lambda_/test_lambda_runtimes.py b/tests/aws/services/lambda_/test_lambda_runtimes.py index 6542229ce6e61..6c0e82bbec038 100644 --- a/tests/aws/services/lambda_/test_lambda_runtimes.py +++ b/tests/aws/services/lambda_/test_lambda_runtimes.py @@ -6,6 +6,7 @@ import json import os import shutil +import textwrap from typing import List import pytest @@ -26,6 +27,7 @@ JAVA_TEST_RUNTIMES, NODE_TEST_RUNTIMES, PYTHON_TEST_RUNTIMES, + TEST_LAMBDA_CLOUDWATCH_LOGS, TEST_LAMBDA_JAVA_MULTIPLE_HANDLERS, TEST_LAMBDA_JAVA_WITH_LIB, TEST_LAMBDA_NODEJS_ES6, @@ -484,3 +486,61 @@ def test_manual_endpoint_injection(self, multiruntime_lambda, tmp_path, aws_clie FunctionName=create_function_result["FunctionName"], ) assert "FunctionError" not in invocation_result + + +class TestCloudwatchLogs: + @pytest.fixture(autouse=True) + def snapshot_transformers(self, snapshot): + snapshot.add_transformer(snapshot.transform.lambda_report_logs()) + snapshot.add_transformer( + snapshot.transform.key_value("eventId", reference_replacement=False) + ) + snapshot.add_transformer( + snapshot.transform.regex(r"::runtime:\w+", "::runtime:") + ) + snapshot.add_transformer(snapshot.transform.regex("\\.v\\d{2}", ".v")) + + @markers.aws.validated + # skip all snapshots - the logs are too different + # TODO add INIT_START to make snapshotting of logs possible + @markers.snapshot.skip_snapshot_verify() + def test_multi_line_prints(self, aws_client, create_lambda_function, snapshot): + function_name = f"test_lambda_{short_uid()}" + log_group_name = f"/aws/lambda/{function_name}" + create_lambda_function( + func_name=function_name, + handler_file=TEST_LAMBDA_CLOUDWATCH_LOGS, + runtime=Runtime.python3_13, + ) + + payload = { + "body": textwrap.dedent(""" + multi + line + string + another\rline + """) + } + invoke_response = aws_client.lambda_.invoke( + FunctionName=function_name, Payload=json.dumps(payload) + ) + snapshot.add_transformer( + snapshot.transform.regex( + invoke_response["ResponseMetadata"]["RequestId"], "" + ) + ) + + def fetch_logs(): + log_events_result = aws_client.logs.filter_log_events(logGroupName=log_group_name) + assert any("REPORT" in e["message"] for e in log_events_result["events"]) + return log_events_result["events"] + + log_events = retry(fetch_logs, retries=10, sleep=2) + snapshot.match("log-events", log_events) + + log_messages = [log["message"] for log in log_events] + # some manual assertions until we can actually use the snapshot + assert "multi\n" in log_messages + assert "line\n" in log_messages + assert "string\n" in log_messages + assert "another\rline\n" in log_messages diff --git a/tests/aws/services/lambda_/test_lambda_runtimes.snapshot.json b/tests/aws/services/lambda_/test_lambda_runtimes.snapshot.json index 248a31729f39f..314aec2afb7e4 100644 --- a/tests/aws/services/lambda_/test_lambda_runtimes.snapshot.json +++ b/tests/aws/services/lambda_/test_lambda_runtimes.snapshot.json @@ -1208,5 +1208,68 @@ } } } + }, + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestCloudwatchLogs::test_multi_line_prints": { + "recorded-date": "02-04-2025, 12:35:33", + "recorded-content": { + "log-events": [ + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "INIT_START Runtime Version: python:3.13.v\tRuntime Version ARN: arn::lambda:::runtime:\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "START RequestId: Version: $LATEST\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "multi\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "line\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "string\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "another\rline\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "END RequestId: \n", + "ingestionTime": "timestamp", + "eventId": "event-id" + }, + { + "logStreamName": "", + "timestamp": "timestamp", + "message": "REPORT RequestId: \tDuration: ms\tBilled Duration: ms\tMemory Size: 128 MB\tMax Memory Used: MB\tInit Duration: ms\t\n", + "ingestionTime": "timestamp", + "eventId": "event-id" + } + ] + } } } diff --git a/tests/aws/services/lambda_/test_lambda_runtimes.validation.json b/tests/aws/services/lambda_/test_lambda_runtimes.validation.json index 0b47eb2edb90c..4d29b8b622534 100644 --- a/tests/aws/services/lambda_/test_lambda_runtimes.validation.json +++ b/tests/aws/services/lambda_/test_lambda_runtimes.validation.json @@ -1,4 +1,7 @@ { + "tests/aws/services/lambda_/test_lambda_runtimes.py::TestCloudwatchLogs::test_multi_line_prints": { + "last_validated_date": "2025-04-02T12:35:33+00:00" + }, "tests/aws/services/lambda_/test_lambda_runtimes.py::TestGoProvidedRuntimes::test_manual_endpoint_injection[provided.al2023]": { "last_validated_date": "2024-11-26T09:46:59+00:00" },