Skip to content

Commit dbfbf55

Browse files
author
Tom McCarthy
authored
feat: add support for tracing of generators using capture_method decorator (#113)
* feat: add support for tracing of generators using capture_method decorator * fix: ensure tracer config is reset before example test runs * chore: refactor to lower complexity of capture_method, update docstring * chore: update docs
1 parent 81539a0 commit dbfbf55

File tree

5 files changed

+247
-35
lines changed

5 files changed

+247
-35
lines changed

aws_lambda_powertools/tracing/tracer.py

+116-32
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import copy
23
import functools
34
import inspect
@@ -320,6 +321,39 @@ def lambda_handler(event: dict, context: Any) -> Dict:
320321
booking_id = event.get("booking_id")
321322
asyncio.run(confirm_booking(booking_id=booking_id))
322323
324+
**Custom generator function using capture_method decorator**
325+
326+
from aws_lambda_powertools import Tracer
327+
tracer = Tracer(service="booking")
328+
329+
@tracer.capture_method
330+
def bookings_generator(booking_id):
331+
resp = call_to_booking_service()
332+
yield resp[0]
333+
yield resp[1]
334+
335+
def lambda_handler(event: dict, context: Any) -> Dict:
336+
gen = bookings_generator(booking_id=booking_id)
337+
result = list(gen)
338+
339+
**Custom generator context manager using capture_method decorator**
340+
341+
from aws_lambda_powertools import Tracer
342+
tracer = Tracer(service="booking")
343+
344+
@tracer.capture_method
345+
@contextlib.contextmanager
346+
def booking_actions(booking_id):
347+
resp = call_to_booking_service()
348+
yield "example result"
349+
cleanup_stuff()
350+
351+
def lambda_handler(event: dict, context: Any) -> Dict:
352+
booking_id = event.get("booking_id")
353+
354+
with booking_actions(booking_id=booking_id) as booking:
355+
result = booking
356+
323357
**Tracing nested async calls**
324358
325359
from aws_lambda_powertools import Tracer
@@ -392,43 +426,93 @@ async def async_tasks():
392426
err
393427
Exception raised by method
394428
"""
395-
method_name = f"{method.__name__}"
396429

397430
if inspect.iscoroutinefunction(method):
431+
decorate = self._decorate_async_function(method=method)
432+
elif inspect.isgeneratorfunction(method):
433+
decorate = self._decorate_generator_function(method=method)
434+
elif hasattr(method, "__wrapped__") and inspect.isgeneratorfunction(method.__wrapped__):
435+
decorate = self._decorate_generator_function_with_context_manager(method=method)
436+
else:
437+
decorate = self._decorate_sync_function(method=method)
398438

399-
@functools.wraps(method)
400-
async def decorate(*args, **kwargs):
401-
async with self.provider.in_subsegment_async(name=f"## {method_name}") as subsegment:
402-
try:
403-
logger.debug(f"Calling method: {method_name}")
404-
response = await method(*args, **kwargs)
405-
self._add_response_as_metadata(function_name=method_name, data=response, subsegment=subsegment)
406-
except Exception as err:
407-
logger.exception(f"Exception received from '{method_name}' method")
408-
self._add_full_exception_as_metadata(
409-
function_name=method_name, error=err, subsegment=subsegment
410-
)
411-
raise
412-
413-
return response
439+
return decorate
414440

415-
else:
441+
def _decorate_async_function(self, method: Callable = None):
442+
method_name = f"{method.__name__}"
443+
444+
@functools.wraps(method)
445+
async def decorate(*args, **kwargs):
446+
async with self.provider.in_subsegment_async(name=f"## {method_name}") as subsegment:
447+
try:
448+
logger.debug(f"Calling method: {method_name}")
449+
response = await method(*args, **kwargs)
450+
self._add_response_as_metadata(function_name=method_name, data=response, subsegment=subsegment)
451+
except Exception as err:
452+
logger.exception(f"Exception received from '{method_name}' method")
453+
self._add_full_exception_as_metadata(function_name=method_name, error=err, subsegment=subsegment)
454+
raise
416455

417-
@functools.wraps(method)
418-
def decorate(*args, **kwargs):
419-
with self.provider.in_subsegment(name=f"## {method_name}") as subsegment:
420-
try:
421-
logger.debug(f"Calling method: {method_name}")
422-
response = method(*args, **kwargs)
423-
self._add_response_as_metadata(function_name=method_name, data=response, subsegment=subsegment)
424-
except Exception as err:
425-
logger.exception(f"Exception received from '{method_name}' method")
426-
self._add_full_exception_as_metadata(
427-
function_name=method_name, error=err, subsegment=subsegment
428-
)
429-
raise
430-
431-
return response
456+
return response
457+
458+
return decorate
459+
460+
def _decorate_generator_function(self, method: Callable = None):
461+
method_name = f"{method.__name__}"
462+
463+
@functools.wraps(method)
464+
def decorate(*args, **kwargs):
465+
with self.provider.in_subsegment(name=f"## {method_name}") as subsegment:
466+
try:
467+
logger.debug(f"Calling method: {method_name}")
468+
result = yield from method(*args, **kwargs)
469+
self._add_response_as_metadata(function_name=method_name, data=result, subsegment=subsegment)
470+
except Exception as err:
471+
logger.exception(f"Exception received from '{method_name}' method")
472+
self._add_full_exception_as_metadata(function_name=method_name, error=err, subsegment=subsegment)
473+
raise
474+
475+
return result
476+
477+
return decorate
478+
479+
def _decorate_generator_function_with_context_manager(self, method: Callable = None):
480+
method_name = f"{method.__name__}"
481+
482+
@functools.wraps(method)
483+
@contextlib.contextmanager
484+
def decorate(*args, **kwargs):
485+
with self.provider.in_subsegment(name=f"## {method_name}") as subsegment:
486+
try:
487+
logger.debug(f"Calling method: {method_name}")
488+
with method(*args, **kwargs) as return_val:
489+
result = return_val
490+
self._add_response_as_metadata(function_name=method_name, data=result, subsegment=subsegment)
491+
except Exception as err:
492+
logger.exception(f"Exception received from '{method_name}' method")
493+
self._add_full_exception_as_metadata(function_name=method_name, error=err, subsegment=subsegment)
494+
raise
495+
496+
yield result
497+
498+
return decorate
499+
500+
def _decorate_sync_function(self, method: Callable = None):
501+
method_name = f"{method.__name__}"
502+
503+
@functools.wraps(method)
504+
def decorate(*args, **kwargs):
505+
with self.provider.in_subsegment(name=f"## {method_name}") as subsegment:
506+
try:
507+
logger.debug(f"Calling method: {method_name}")
508+
response = method(*args, **kwargs)
509+
self._add_response_as_metadata(function_name=method_name, data=response, subsegment=subsegment)
510+
except Exception as err:
511+
logger.exception(f"Exception received from '{method_name}' method")
512+
self._add_full_exception_as_metadata(function_name=method_name, error=err, subsegment=subsegment)
513+
raise
514+
515+
return response
432516

433517
return decorate
434518

docs/content/core/tracer.mdx

+25-3
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Tracer is an opinionated thin wrapper for [AWS X-Ray Python SDK](https://github.
1414
* Capture cold start as annotation, and responses as well as full exceptions as metadata
1515
* Run functions locally with SAM CLI without code change to disable tracing
1616
* Explicitly disable tracing via env var `POWERTOOLS_TRACE_DISABLED="true"`
17-
* Support tracing async methods
17+
* Support tracing async methods, generators, and context managers
1818
* Auto patch supported modules, or a tuple of explicit modules supported by AWS X-Ray
1919

2020
## Initialization
@@ -111,16 +111,18 @@ def collect_payment(charge_id):
111111
...
112112
```
113113

114-
## Asynchronous functions
114+
## Asynchronous and generator functions
115115

116116
<Note type="warning">
117117
<strong>We do not support async Lambda handler</strong> - Lambda handler itself must be synchronous
118118
</Note><br/>
119119

120-
You can trace an asynchronous function using the `capture_method`. The decorator will detect whether your function is asynchronous, and adapt its behaviour accordingly.
120+
You can trace asynchronous functions and generator functions (including context managers) using `capture_method`.
121+
The decorator will detect whether your function is asynchronous, a generator, or a context manager and adapt its behaviour accordingly.
121122

122123
```python:title=lambda_handler_with_async_code.py
123124
import asyncio
125+
import contextlib
124126
from aws_lambda_powertools import Tracer
125127
tracer = Tracer()
126128

@@ -130,9 +132,29 @@ async def collect_payment():
130132
...
131133
# highlight-end
132134

135+
# highlight-start
136+
@contextlib.contextmanager
137+
@tracer.capture_method
138+
def collect_payment_ctxman():
139+
yield result
140+
...
141+
# highlight-end
142+
143+
# highlight-start
144+
@tracer.capture_method
145+
def collect_payment_gen():
146+
yield result
147+
...
148+
# highlight-end
149+
133150
@tracer.capture_lambda_handler
134151
def handler(evt, ctx): # highlight-line
135152
asyncio.run(collect_payment())
153+
154+
with collect_payment_ctxman as result:
155+
do_something_with(result)
156+
157+
another_result = list(collect_payment_gen())
136158
```
137159

138160
## Tracing aiohttp requests

example/tests/test_handler.py

+8
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@
44

55
import pytest
66

7+
from aws_lambda_powertools import Tracer
8+
9+
10+
@pytest.fixture(scope="function", autouse=True)
11+
def reset_tracing_config():
12+
Tracer._reset_config()
13+
yield
14+
715

816
@pytest.fixture()
917
def env_vars(monkeypatch):

tests/functional/test_tracing.py

+35
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import contextlib
2+
13
import pytest
24

35
from aws_lambda_powertools import Tracer
@@ -150,3 +152,36 @@ def sums_values():
150152
return func_1() + func_2()
151153

152154
sums_values()
155+
156+
157+
def test_tracer_yield_with_capture():
158+
# GIVEN tracer method decorator is used
159+
tracer = Tracer(disabled=True)
160+
161+
# WHEN capture_method decorator is applied to a context manager
162+
@tracer.capture_method
163+
@contextlib.contextmanager
164+
def yield_with_capture():
165+
yield "testresult"
166+
167+
# Or WHEN capture_method decorator is applied to a generator function
168+
@tracer.capture_method
169+
def generator_func():
170+
yield "testresult2"
171+
172+
@tracer.capture_lambda_handler
173+
def handler(event, context):
174+
result = []
175+
with yield_with_capture() as yielded_value:
176+
result.append(yielded_value)
177+
178+
gen = generator_func()
179+
180+
result.append(next(gen))
181+
182+
return result
183+
184+
# THEN no exception is thrown, and the functions properly return values
185+
result = handler({}, {})
186+
assert "testresult" in result
187+
assert "testresult2" in result

tests/unit/test_tracing.py

+63
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import contextlib
12
import sys
23
from typing import NamedTuple
34
from unittest import mock
@@ -348,3 +349,65 @@ async def greeting(name, message):
348349
put_metadata_mock_args = in_subsegment_mock.put_metadata.call_args[1]
349350
assert put_metadata_mock_args["key"] == "greeting error"
350351
assert put_metadata_mock_args["namespace"] == "booking"
352+
353+
354+
def test_tracer_yield_from_context_manager(mocker, provider_stub, in_subsegment_mock):
355+
# GIVEN tracer is initialized
356+
provider = provider_stub(in_subsegment=in_subsegment_mock.in_subsegment)
357+
tracer = Tracer(provider=provider, service="booking")
358+
359+
# WHEN capture_method decorator is used on a context manager
360+
@tracer.capture_method
361+
@contextlib.contextmanager
362+
def yield_with_capture():
363+
yield "test result"
364+
365+
@tracer.capture_lambda_handler
366+
def handler(event, context):
367+
response = []
368+
with yield_with_capture() as yielded_value:
369+
response.append(yielded_value)
370+
371+
return response
372+
373+
result = handler({}, {})
374+
375+
# THEN we should have a subsegment named after the method name
376+
# and add its response as trace metadata
377+
handler_trace, yield_function_trace = in_subsegment_mock.in_subsegment.call_args_list
378+
379+
assert "test result" in in_subsegment_mock.put_metadata.call_args[1]["value"]
380+
assert in_subsegment_mock.in_subsegment.call_count == 2
381+
assert handler_trace == mocker.call(name="## handler")
382+
assert yield_function_trace == mocker.call(name="## yield_with_capture")
383+
assert "test result" in result
384+
385+
386+
def test_tracer_yield_from_generator(mocker, provider_stub, in_subsegment_mock):
387+
# GIVEN tracer is initialized
388+
provider = provider_stub(in_subsegment=in_subsegment_mock.in_subsegment)
389+
tracer = Tracer(provider=provider, service="booking")
390+
391+
# WHEN capture_method decorator is used on a generator function
392+
@tracer.capture_method
393+
def generator_fn():
394+
yield "test result"
395+
396+
@tracer.capture_lambda_handler
397+
def handler(event, context):
398+
gen = generator_fn()
399+
response = list(gen)
400+
401+
return response
402+
403+
result = handler({}, {})
404+
405+
# THEN we should have a subsegment named after the method name
406+
# and add its response as trace metadata
407+
handler_trace, generator_fn_trace = in_subsegment_mock.in_subsegment.call_args_list
408+
409+
assert "test result" in in_subsegment_mock.put_metadata.call_args[1]["value"]
410+
assert in_subsegment_mock.in_subsegment.call_count == 2
411+
assert handler_trace == mocker.call(name="## handler")
412+
assert generator_fn_trace == mocker.call(name="## generator_fn")
413+
assert "test result" in result

0 commit comments

Comments
 (0)