Skip to content

Commit a3255e9

Browse files
feat(decorators): Support Durable Context in logger and metric decorators (#7765)
* Support Durable Function Context in logger and metric decorators This commit updates the Logger and Metric decorators to handle DurableContexts. If a DurableContext is present, it is unwrapped to access the Lambda Context * Add tests * Extract is_durable_context type guard * Add tests to verify inject_lambda_context handles durable context * Add tests to verify log_metrics handles durable context * Test base provider * Fix test * Test the base provider using Single Metric class --------- Co-authored-by: Leandro Damascena <lcdama@amazon.pt>
1 parent 11b68ff commit a3255e9

File tree

8 files changed

+145
-8
lines changed

8 files changed

+145
-8
lines changed

aws_lambda_powertools/logging/logger.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from aws_lambda_powertools.shared.functions import (
3939
extract_event_from_common_models,
4040
get_tracer_id,
41+
is_durable_context,
4142
resolve_env_var_choice,
4243
resolve_truthy_env_var_choice,
4344
)
@@ -520,13 +521,18 @@ def handler(event, context):
520521

521522
@functools.wraps(lambda_handler)
522523
def decorate(event, context, *args, **kwargs):
523-
lambda_context = build_lambda_context_model(context)
524+
unwrapped_context = (
525+
build_lambda_context_model(context.lambda_context)
526+
if is_durable_context(context)
527+
else build_lambda_context_model(context)
528+
)
529+
524530
cold_start = _is_cold_start()
525531

526532
if clear_state:
527-
self.structure_logs(cold_start=cold_start, **lambda_context.__dict__)
533+
self.structure_logs(cold_start=cold_start, **unwrapped_context.__dict__)
528534
else:
529-
self.append_keys(cold_start=cold_start, **lambda_context.__dict__)
535+
self.append_keys(cold_start=cold_start, **unwrapped_context.__dict__)
530536

531537
if correlation_id_path:
532538
self.set_correlation_id(

aws_lambda_powertools/metrics/base.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
reset_cold_start_flag, # noqa: F401 # backwards compatibility
3838
)
3939
from aws_lambda_powertools.shared import constants
40-
from aws_lambda_powertools.shared.functions import resolve_env_var_choice
40+
from aws_lambda_powertools.shared.functions import is_durable_context, resolve_env_var_choice
4141

4242
if TYPE_CHECKING:
4343
from collections.abc import Callable, Generator
@@ -430,12 +430,13 @@ def handler(event, context):
430430

431431
@functools.wraps(lambda_handler)
432432
def decorate(event, context, *args, **kwargs):
433+
unwrapped_context = context.lambda_context if is_durable_context(context) else context
433434
try:
434435
if default_dimensions:
435436
self.set_default_dimensions(**default_dimensions)
436-
response = lambda_handler(event, context, *args, **kwargs)
437+
response = lambda_handler(event, unwrapped_context, *args, **kwargs)
437438
if capture_cold_start_metric:
438-
self._add_cold_start_metric(context=context)
439+
self._add_cold_start_metric(context=unwrapped_context)
439440
finally:
440441
self.flush_metrics(raise_on_empty_metrics=raise_on_empty_metrics)
441442

aws_lambda_powertools/metrics/provider/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import TYPE_CHECKING, Any
77

88
from aws_lambda_powertools.metrics.provider import cold_start
9+
from aws_lambda_powertools.shared.functions import is_durable_context
910

1011
if TYPE_CHECKING:
1112
from aws_lambda_powertools.shared.types import AnyCallableT
@@ -206,7 +207,8 @@ def decorate(event, context, *args, **kwargs):
206207
try:
207208
response = lambda_handler(event, context, *args, **kwargs)
208209
if capture_cold_start_metric:
209-
self._add_cold_start_metric(context=context)
210+
unwrapped_context = context.lambda_context if is_durable_context(context) else context
211+
self._add_cold_start_metric(context=unwrapped_context)
210212
finally:
211213
self.flush_metrics(raise_on_empty_metrics=raise_on_empty_metrics)
212214

aws_lambda_powertools/shared/functions.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
import warnings
99
from binascii import Error as BinAsciiError
1010
from pathlib import Path
11-
from typing import TYPE_CHECKING, Any, overload
11+
from typing import TYPE_CHECKING, Any, TypeGuard, overload
1212

1313
from aws_lambda_powertools.shared import constants
1414

1515
if TYPE_CHECKING:
1616
from collections.abc import Generator
1717

18+
from aws_lambda_powertools.utilities.typing import DurableContextProtocol
19+
1820
logger = logging.getLogger(__name__)
1921

2022

@@ -307,3 +309,8 @@ def decode_header_bytes(byte_list):
307309
# Convert signed bytes to unsigned (0-255 range)
308310
unsigned_bytes = [(b & 0xFF) for b in byte_list]
309311
return bytes(unsigned_bytes)
312+
313+
314+
def is_durable_context(context: Any) -> TypeGuard[DurableContextProtocol]:
315+
"""Check if context is a Step Functions durable context wrapping a Lambda context."""
316+
return hasattr(context, "state") and hasattr(context, "lambda_context")

tests/functional/logger/required_dependencies/test_logger.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ def lambda_context():
4848
return namedtuple("LambdaContext", lambda_context.keys())(*lambda_context.values())
4949

5050

51+
@pytest.fixture
52+
def durable_context(lambda_context):
53+
return namedtuple("DurableContext", ["state", "lambda_context"])(state={}, lambda_context=lambda_context)
54+
55+
5156
@pytest.fixture
5257
def lambda_event():
5358
return {"greeting": "hello"}
@@ -1578,3 +1583,20 @@ def test_child_logger_with_caplog(caplog):
15781583

15791584
assert len(caplog.records) == 1
15801585
assert pytest_handler_existence is True
1586+
1587+
1588+
def test_logger_with_durable_context(lambda_context, durable_context, stdout, service_name):
1589+
# GIVEN Logger is initialized and a durable context wrapping the lambda context
1590+
logger = Logger(service=service_name, stream=stdout)
1591+
1592+
@logger.inject_lambda_context
1593+
def handler(event, context):
1594+
logger.info("Hello")
1595+
1596+
# WHEN handler is called with durable context
1597+
handler({}, durable_context)
1598+
1599+
# THEN lambda contextual info should be extracted from durable context
1600+
log = capture_logging_output(stdout)
1601+
assert log["function_name"] == lambda_context.function_name
1602+
assert log["function_request_id"] == lambda_context.aws_request_id

tests/functional/metrics/conftest.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from collections import namedtuple
34
from typing import Any
45

56
import pytest
@@ -96,3 +97,13 @@ def a_hundred_metrics() -> list[dict[str, str]]:
9697
@pytest.fixture
9798
def a_hundred_metric_values() -> list[dict[str, str]]:
9899
return [{"name": "metric", "unit": "Count", "value": i} for i in range(100)]
100+
101+
102+
@pytest.fixture
103+
def lambda_context():
104+
return namedtuple("LambdaContext", "function_name")("example_fn")
105+
106+
107+
@pytest.fixture
108+
def durable_context(lambda_context):
109+
return namedtuple("DurableContext", ["state", "lambda_context"])(state={}, lambda_context=lambda_context)

tests/functional/metrics/required_dependencies/test_metrics_cloudwatch_emf.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
SchemaValidationError,
2020
single_metric,
2121
)
22+
from aws_lambda_powertools.metrics.base import SingleMetric
2223
from aws_lambda_powertools.metrics.provider.cloudwatch_emf.cloudwatch import (
2324
AmazonCloudWatchEMFProvider,
2425
)
@@ -1573,3 +1574,60 @@ def test_metrics_disabled_with_dev_mode_false_and_metrics_disabled_true(monkeypa
15731574
# THEN no metrics should have been recorded
15741575
captured = capsys.readouterr()
15751576
assert not captured.out
1577+
1578+
1579+
def test_log_metrics_with_durable_context(capsys, metrics, dimensions, namespace, durable_context):
1580+
# GIVEN Metrics is initialized and a durable context wrapping the lambda context
1581+
my_metrics = Metrics(namespace=namespace)
1582+
for metric in metrics:
1583+
my_metrics.add_metric(**metric)
1584+
for dimension in dimensions:
1585+
my_metrics.add_dimension(**dimension)
1586+
1587+
@my_metrics.log_metrics
1588+
def lambda_handler(evt, ctx):
1589+
pass
1590+
1591+
# WHEN handler is called with durable context
1592+
lambda_handler({}, durable_context)
1593+
output = capture_metrics_output(capsys)
1594+
expected = serialize_metrics(metrics=metrics, dimensions=dimensions, namespace=namespace)
1595+
1596+
# THEN metrics should be flushed correctly
1597+
remove_timestamp(metrics=[output, expected])
1598+
assert expected == output
1599+
1600+
1601+
def test_log_metrics_capture_cold_start_metric_with_durable_context(capsys, namespace, service, durable_context):
1602+
# GIVEN Metrics is initialized and a durable context wrapping the lambda context
1603+
my_metrics = Metrics(service=service, namespace=namespace)
1604+
1605+
@my_metrics.log_metrics(capture_cold_start_metric=True)
1606+
def lambda_handler(evt, context):
1607+
pass
1608+
1609+
# WHEN handler is called with durable context
1610+
lambda_handler({}, durable_context)
1611+
output = capture_metrics_output(capsys)
1612+
1613+
# THEN ColdStart metric should use function_name from unwrapped lambda context
1614+
assert output["ColdStart"] == [1.0]
1615+
assert output["function_name"] == "example_fn"
1616+
1617+
1618+
def test_single_metric_log_metrics_with_durable_context(capsys, namespace, durable_context):
1619+
# GIVEN SingleMetric is initialized with a durable context
1620+
metric = SingleMetric(namespace=namespace)
1621+
1622+
@metric.log_metrics(capture_cold_start_metric=True)
1623+
def lambda_handler(evt, ctx):
1624+
metric.add_metric(name="TestMetric", unit=MetricUnit.Count, value=1)
1625+
1626+
# WHEN handler is called with durable context
1627+
lambda_handler({}, durable_context)
1628+
output = capsys.readouterr().out.strip().split("\n")
1629+
1630+
# THEN cold start metric should use function_name from unwrapped context
1631+
cold_start_output = json.loads(output[0])
1632+
assert cold_start_output["ColdStart"] == [1.0]
1633+
assert cold_start_output["function_name"] == "example_fn"

tests/functional/metrics/required_dependencies/test_metrics_provider.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,33 @@ def lambda_handler(evt, context, additional_arg, additional_kw_arg="default_valu
7878
# the wrapped function is passed additional arguments
7979
assert lambda_handler({}, {}, "arg_value", additional_kw_arg="kw_arg_value") == ("arg_value", "kw_arg_value")
8080
assert lambda_handler({}, {}, "arg_value") == ("arg_value", "default_value")
81+
82+
83+
def test_log_metrics_with_durable_context(capsys, metric, durable_context):
84+
provider = FakeMetricsProvider()
85+
metrics = Metrics(provider=provider)
86+
87+
@metrics.log_metrics
88+
def lambda_handler(evt, ctx):
89+
metrics.add_metric(**metric)
90+
91+
lambda_handler({}, durable_context)
92+
output = capture_metrics_output(capsys)
93+
94+
assert output[0]["name"] == metric["name"]
95+
assert output[0]["value"] == metric["value"]
96+
97+
98+
def test_log_metrics_cold_start_with_durable_context(capsys, durable_context):
99+
provider = FakeMetricsProvider()
100+
metrics = Metrics(provider=provider)
101+
102+
@metrics.log_metrics(capture_cold_start_metric=True)
103+
def lambda_handler(evt, ctx):
104+
return True
105+
106+
lambda_handler({}, durable_context)
107+
output = capture_metrics_output(capsys)
108+
109+
assert output[0]["name"] == "ColdStart"
110+
assert output[0]["value"] == 1

0 commit comments

Comments
 (0)