diff --git a/aws_lambda_powertools/tracing/otel/__init__.py b/aws_lambda_powertools/tracing/otel/__init__.py new file mode 100644 index 00000000000..a6832ea56f9 --- /dev/null +++ b/aws_lambda_powertools/tracing/otel/__init__.py @@ -0,0 +1,6 @@ +"""OpenTelemetry Tracer for AWS Lambda Powertools""" + +from aws_lambda_powertools.tracing.otel.propagation import create_span_from_context, inject_trace_context +from aws_lambda_powertools.tracing.otel.tracer import TracerOpenTelemetry + +__all__ = ["TracerOpenTelemetry", "inject_trace_context", "create_span_from_context"] diff --git a/aws_lambda_powertools/tracing/otel/propagation.py b/aws_lambda_powertools/tracing/otel/propagation.py new file mode 100644 index 00000000000..87067192d6b --- /dev/null +++ b/aws_lambda_powertools/tracing/otel/propagation.py @@ -0,0 +1,76 @@ +"""Context propagation helpers for OpenTelemetry tracing.""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Generator + + from opentelemetry.trace import Span + + +def inject_trace_context(carrier: dict[str, Any]) -> dict[str, Any]: + """Inject current trace context into a carrier dict. + + Use this to propagate trace context through SQS messages. + + Parameters + ---------- + carrier : dict + Dictionary to inject trace context into. + + Returns + ------- + dict + Carrier with trace context injected. + + Example + ------- + message = {"data": "payload"} + message = inject_trace_context(message) + sqs.send_message(QueueUrl=url, MessageBody=json.dumps(message)) + """ + from opentelemetry.propagate import inject + + inject(carrier) + return carrier + + +@contextlib.contextmanager +def create_span_from_context( + name: str, + carrier: dict[str, Any], + **kwargs, +) -> Generator[Span, None, None]: + """Create a span with parent context extracted from carrier. + + Use this to continue a trace from an SQS message. + + Parameters + ---------- + name : str + Span name. + carrier : dict + Dictionary containing trace context (e.g., SQS message body). + **kwargs + Additional arguments passed to start_as_current_span. + + Example + ------- + message = json.loads(record["body"]) + with create_span_from_context("process_message", message) as span: + process(message["data"]) + """ + from opentelemetry import trace + from opentelemetry.propagate import extract + + ctx = extract(carrier) + tracer = trace.get_tracer("aws_lambda_powertools") + + kwargs.setdefault("record_exception", True) + kwargs.setdefault("set_status_on_exception", True) + + with tracer.start_as_current_span(name=name, context=ctx, **kwargs) as span: + yield span diff --git a/aws_lambda_powertools/tracing/otel/tracer.py b/aws_lambda_powertools/tracing/otel/tracer.py new file mode 100644 index 00000000000..15e9dca6286 --- /dev/null +++ b/aws_lambda_powertools/tracing/otel/tracer.py @@ -0,0 +1,411 @@ +"""OpenTelemetry Tracer implementation for AWS Lambda Powertools""" + +from __future__ import annotations + +import contextlib +import functools +import inspect +import logging +import os +from typing import TYPE_CHECKING, Literal, TypeVar + +from aws_lambda_powertools.shared import constants +from aws_lambda_powertools.shared.functions import resolve_env_var_choice, resolve_truthy_env_var_choice + +if TYPE_CHECKING: + from collections.abc import Callable, Generator + + from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider + from opentelemetry.trace import Span, Tracer, TracerProvider + +logger = logging.getLogger(__name__) + +is_cold_start = True +T = TypeVar("T") + + +def _is_cold_start() -> bool: + """Check if this is a cold start invocation.""" + global is_cold_start + + if os.getenv(constants.LAMBDA_INITIALIZATION_TYPE) == "provisioned-concurrency": + is_cold_start = False + return False + + if not is_cold_start: + return False + + is_cold_start = False + return True + + +class TracerOpenTelemetry: + """OpenTelemetry Tracer for AWS Lambda with Powertools conventions. + + Parameters + ---------- + mode : Literal["auto", "manual"] + Instrumentation mode. "auto" uses global TracerProvider from OTel SDK (e.g., ADOT). + "manual" uses provided TracerProvider or creates default one. + service : str, optional + Service name for spans. Falls back to POWERTOOLS_SERVICE_NAME or Lambda function name. + tracer_provider : TracerProvider, optional + Custom TracerProvider. Only valid in manual mode. + disabled : bool, optional + Disable tracing. Falls back to POWERTOOLS_TRACE_DISABLED env var. + + Example + ------- + **Auto mode with ADOT Lambda Layer:** + + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="auto") + + @tracer.capture_lambda_handler + def handler(event, context): + return {"statusCode": 200} + + **Manual mode with custom TracerProvider:** + + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import BatchSpanProcessor + from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter + + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider) + """ + + def __init__( + self, + mode: Literal["auto", "manual"] = "manual", + service: str | None = None, + tracer_provider: SDKTracerProvider | None = None, + disabled: bool | None = None, + ): + self.mode = mode + self.disabled = self._resolve_disabled(disabled) + self.service = self._resolve_service(service) + self._tracer_provider = self._resolve_tracer_provider(tracer_provider) + self._tracer: Tracer | None = None + + def _resolve_disabled(self, disabled: bool | None) -> bool: + """Resolve disabled state from parameter or environment.""" + if disabled is not None: + return disabled + return resolve_truthy_env_var_choice(env=os.getenv(constants.TRACER_DISABLED_ENV, "false")) + + def _resolve_service(self, service: str | None) -> str: + """Resolve service name from parameter, environment, or Lambda context.""" + if service: + return service + return resolve_env_var_choice( + choice=service, + env=os.getenv(constants.SERVICE_NAME_ENV) or os.getenv("AWS_LAMBDA_FUNCTION_NAME", "service_undefined"), + ) + + def _resolve_tracer_provider(self, tracer_provider: SDKTracerProvider | None) -> SDKTracerProvider | None: + """Resolve TracerProvider based on mode.""" + if self.disabled: + return None + + if self.mode == "auto": + if tracer_provider is not None: + raise ValueError("tracer_provider cannot be provided in auto mode") + return None # Will use global provider + + # Manual mode: use provided or create default + if tracer_provider is not None: + return tracer_provider + + # Create default TracerProvider + from opentelemetry.sdk.trace import TracerProvider as SDKTracerProvider + + return SDKTracerProvider() + + @property + def provider(self) -> TracerProvider: + """Get the TracerProvider.""" + if self._tracer_provider is not None: + return self._tracer_provider + + from opentelemetry.trace import get_tracer_provider + + return get_tracer_provider() # type: ignore[return-value] + + def _get_tracer(self) -> Tracer: + """Get or create the Tracer instance.""" + if self._tracer is None: + self._tracer = self.provider.get_tracer( + instrumenting_module_name="aws_lambda_powertools", + instrumenting_library_version="1.0.0", + ) + return self._tracer + + def capture_lambda_handler( + self, + lambda_handler: Callable | None = None, + capture_response: bool | None = None, + capture_error: bool | None = None, + ) -> Callable: + """Decorator to trace Lambda handler with cold start detection. + + Parameters + ---------- + capture_response : bool, optional + Capture response as span attribute. Default True. + capture_error : bool, optional + Capture errors as span events. Default True. + """ + if lambda_handler is None: + return functools.partial( + self.capture_lambda_handler, + capture_response=capture_response, + capture_error=capture_error, + ) + + capture_response = resolve_truthy_env_var_choice( + env=os.getenv(constants.TRACER_CAPTURE_RESPONSE_ENV, "true"), + choice=capture_response, + ) + capture_error = resolve_truthy_env_var_choice( + env=os.getenv(constants.TRACER_CAPTURE_ERROR_ENV, "true"), + choice=capture_error, + ) + + @functools.wraps(lambda_handler) + def decorate(event, context, **kwargs): + if self.disabled: + return lambda_handler(event, context, **kwargs) + + tracer = self._get_tracer() + with tracer.start_as_current_span( + name=lambda_handler.__name__, + record_exception=capture_error, + set_status_on_exception=True, + ) as span: + self._add_lambda_attributes(span) + + try: + response = lambda_handler(event, context, **kwargs) + if capture_response and response is not None: + span.set_attribute(f"{lambda_handler.__name__}.response", str(response)[:1024]) + return response + except Exception as err: + if capture_error: + span.record_exception(err) + raise + + return decorate + + def _add_lambda_attributes(self, span: Span) -> None: + """Add Lambda-specific attributes to span.""" + cold_start = _is_cold_start() + span.set_attribute("faas.coldstart", cold_start) + span.set_attribute("service.name", self.service) + + def capture_method( + self, + method: Callable | None = None, + capture_response: bool | None = None, + capture_error: bool | None = None, + ) -> Callable: + """Decorator to trace methods as child spans. + + Parameters + ---------- + capture_response : bool, optional + Capture response as span attribute. Default True. + capture_error : bool, optional + Capture errors as span events. Default True. + """ + if method is None: + return functools.partial( + self.capture_method, + capture_response=capture_response, + capture_error=capture_error, + ) + + method_name = f"{method.__module__}.{method.__qualname__}" + + capture_response = resolve_truthy_env_var_choice( + env=os.getenv(constants.TRACER_CAPTURE_RESPONSE_ENV, "true"), + choice=capture_response, + ) + capture_error = resolve_truthy_env_var_choice( + env=os.getenv(constants.TRACER_CAPTURE_ERROR_ENV, "true"), + choice=capture_error, + ) + + if inspect.iscoroutinefunction(method): + return self._decorate_async(method, method_name, capture_response, capture_error) + elif inspect.isgeneratorfunction(method): + return self._decorate_generator(method, method_name, capture_response, capture_error) + else: + return self._decorate_sync(method, method_name, capture_response, capture_error) + + def _decorate_sync( + self, + method: Callable, + method_name: str, + capture_response: bool, + capture_error: bool, + ) -> Callable: + @functools.wraps(method) + def decorate(*args, **kwargs): + if self.disabled: + return method(*args, **kwargs) + + tracer = self._get_tracer() + with tracer.start_as_current_span( + name=method_name, + record_exception=capture_error, + set_status_on_exception=True, + ) as span: + try: + response = method(*args, **kwargs) + if capture_response and response is not None: + span.set_attribute(f"{method_name}.response", str(response)[:1024]) + return response + except Exception as err: + if capture_error: + span.record_exception(err) + raise + + return decorate + + def _decorate_async( + self, + method: Callable, + method_name: str, + capture_response: bool, + capture_error: bool, + ) -> Callable: + @functools.wraps(method) + async def decorate(*args, **kwargs): + if self.disabled: + return await method(*args, **kwargs) + + tracer = self._get_tracer() + with tracer.start_as_current_span( + name=method_name, + record_exception=capture_error, + set_status_on_exception=True, + ) as span: + try: + response = await method(*args, **kwargs) + if capture_response and response is not None: + span.set_attribute(f"{method_name}.response", str(response)[:1024]) + return response + except Exception as err: + if capture_error: + span.record_exception(err) + raise + + return decorate + + def _decorate_generator( + self, + method: Callable, + method_name: str, + capture_response: bool, + capture_error: bool, + ) -> Callable: + @functools.wraps(method) + def decorate(*args, **kwargs): + if self.disabled: + yield from method(*args, **kwargs) + return + + tracer = self._get_tracer() + with tracer.start_as_current_span( + name=method_name, + record_exception=capture_error, + set_status_on_exception=True, + ) as span: + try: + response = yield from method(*args, **kwargs) + if capture_response and response is not None: + span.set_attribute(f"{method_name}.response", str(response)[:1024]) + return response + except Exception as err: + if capture_error: + span.record_exception(err) + raise + + return decorate + + @contextlib.contextmanager + def add_span( + self, + name: str, + **kwargs, + ) -> Generator[Span, None, None]: + """Context manager to create a child span. + + Parameters + ---------- + name : str + Span name. + **kwargs + Additional arguments passed to start_as_current_span. + + Example + ------- + with tracer.add_span("process_data") as span: + span.set_attribute("items", 10) + process() + """ + if self.disabled: + yield None # type: ignore[misc] + return + + tracer = self._get_tracer() + kwargs.setdefault("record_exception", True) + kwargs.setdefault("set_status_on_exception", True) + + with tracer.start_as_current_span(name=name, **kwargs) as span: + yield span + + def get_current_span(self) -> Span | None: + """Get the current active span. + + Returns + ------- + Span | None + Current span or None if no active span. + """ + if self.disabled: + return None + + from opentelemetry.trace import get_current_span + + return get_current_span() + + def instrument_requests(self, ignore_urls: list[str] | None = None) -> None: + """Configure requests library instrumentation with URL filtering. + + Parameters + ---------- + ignore_urls : list[str], optional + List of URL patterns to exclude from tracing. + + Note + ---- + Requires opentelemetry-instrumentation-requests package. + """ + if self.disabled: + return + + try: + from opentelemetry.instrumentation.requests import RequestsInstrumentor # type: ignore[import-not-found] + + if ignore_urls: + os.environ["OTEL_PYTHON_REQUESTS_EXCLUDED_URLS"] = ",".join(ignore_urls) + + RequestsInstrumentor().instrument() + except ImportError: + logger.warning("opentelemetry-instrumentation-requests not installed, skipping instrumentation") diff --git a/docs/core/tracer-otel.md b/docs/core/tracer-otel.md new file mode 100644 index 00000000000..fde59da61d1 --- /dev/null +++ b/docs/core/tracer-otel.md @@ -0,0 +1,187 @@ +--- +title: Tracer (OpenTelemetry) +description: Core utility +--- + +TracerOpenTelemetry is an OpenTelemetry-native tracer for AWS Lambda, providing distributed tracing with the [OpenTelemetry SDK](https://opentelemetry.io/docs/languages/python/){target="_blank" rel="nofollow"}. + +## Key features + +* Two modes: **auto** (ADOT Layer) and **manual** (custom TracerProvider) +* Auto capture cold start as span attribute +* Auto-disable when `POWERTOOLS_TRACE_DISABLED` is set +* Support tracing async methods, generators, and context managers +* SQS context propagation helpers + +## Getting started + +???+ tip + All examples shared in this documentation are available within the [project repository](https://github.com/aws-powertools/powertools-lambda-python/tree/develop/examples){target="_blank"}. + +### Install + +!!! info "This is not necessary if you're installing Powertools for AWS Lambda (Python) via [Lambda Layer/SAR](../index.md#lambda-layer){target="_blank"}" + +Add `aws-lambda-powertools[otel]` as a dependency in your preferred tool: _e.g._, _requirements.txt_, _pyproject.toml_. This will ensure you have the required dependencies before using TracerOpenTelemetry. + +### Auto mode (ADOT Layer) + +Use **auto mode** when deploying with the [AWS Distro for OpenTelemetry (ADOT) Lambda Layer](https://aws-otel.github.io/docs/getting-started/lambda){target="_blank" rel="nofollow"}. The ADOT Layer configures a global TracerProvider that exports spans automatically. + +```python hl_lines="1 3 7" title="Auto mode with ADOT Layer" +--8<-- "examples/tracer_otel/src/getting_started_auto_mode.py" +``` + +`capture_lambda_handler` performs these additional tasks to ease operations: + +* Adds a `faas.coldstart` attribute to easily filter traces that have had an initialization overhead +* Adds a `service.name` attribute if `service` parameter or `POWERTOOLS_SERVICE_NAME` is set +* Captures any response, or full exceptions generated by the handler, and includes as span attributes + +### Manual mode + +Use **manual mode** when you want full control over the TracerProvider configuration, or when not using the ADOT Layer. + +```python hl_lines="1-3 8-9 11" title="Manual mode with custom TracerProvider" +--8<-- "examples/tracer_otel/src/getting_started_manual_mode.py" +``` + +If you don't provide a `tracer_provider`, a default TracerProvider is created that respects OpenTelemetry SDK environment variables. + +### Synchronous functions + +You can trace synchronous functions using the `capture_method` decorator. + +```python hl_lines="7-8 12" title="Tracing an arbitrary function with capture_method" +--8<-- "examples/tracer_otel/src/capture_method.py" +``` + +### Asynchronous functions + +You can trace asynchronous functions using `capture_method`. + +```python hl_lines="8-10" title="Tracing async functions" +--8<-- "examples/tracer_otel/src/capture_method_async.py" +``` + +### Creating custom spans + +Use `add_span` context manager to create child spans with custom attributes: + +```python hl_lines="8-11" title="Creating custom spans with add_span" +--8<-- "examples/tracer_otel/src/add_span.py" +``` + +### Environment variables + +The following environment variables are available to configure TracerOpenTelemetry at a global scope: + +| Setting | Description | Environment variable | Default | +|-----------------------|--------------------------------------------------|--------------------------------------|---------------| +| **Disable Tracing** | Explicitly disables all tracing. | `POWERTOOLS_TRACE_DISABLED` | `false` | +| **Service Name** | Sets the service name for spans. | `POWERTOOLS_SERVICE_NAME` | Function name | +| **Response Capture** | Captures Lambda or method return as attribute. | `POWERTOOLS_TRACER_CAPTURE_RESPONSE` | `true` | +| **Exception Capture** | Records exceptions in spans. | `POWERTOOLS_TRACER_CAPTURE_ERROR` | `true` | + +## Advanced + +### Disabling response auto-capture + +Use **`capture_response=False`** parameter in both `capture_lambda_handler` and `capture_method` decorators to instruct Tracer **not** to serialize function responses as span attributes. + +???+ info "Info: This is useful in two common scenarios" + 1. You might **return sensitive** information you don't want it to be added to your traces + 2. You might return **more than 1KB** of data which exceeds span attribute limits + +```python hl_lines="6" title="Disabling response capture" +--8<-- "examples/tracer_otel/src/disable_capture_response.py" +``` + +### SQS context propagation + +Propagate trace context through SQS messages to maintain distributed traces across services. This requires two separate Lambda functions: a **producer** that sends messages and a **consumer** that processes them. + +The producer injects trace context into the message payload before sending to SQS. The consumer extracts this context to continue the same trace, linking both functions in your distributed trace. + +=== "Producer Handler" + + ```python hl_lines="4 15-16" title="Inject trace context before sending to SQS" + --8<-- "examples/tracer_otel/src/sqs_propagation_producer.py" + ``` + +=== "Consumer Handler" + + ```python hl_lines="4 16" title="Extract trace context from SQS message" + --8<-- "examples/tracer_otel/src/sqs_propagation_consumer.py" + ``` + +### Cold start detection + +TracerOpenTelemetry automatically adds a `faas.coldstart` attribute to the handler span: + +* `true` on the first invocation after a cold start +* `false` on subsequent warm invocations + +### Accessing the current span + +You can use `get_current_span` method to access the current active span and add custom attributes. + +```python +span = tracer.get_current_span() +if span: + span.set_attribute("custom_key", "custom_value") +``` + +## Testing your code + +TracerOpenTelemetry is disabled by default when `POWERTOOLS_TRACE_DISABLED` environment variable is set to `true`. This means you can disable tracing during tests without code changes. + +## Comparison with X-Ray Tracer + +| Feature | Tracer (X-Ray) | TracerOpenTelemetry | +|---------|---------------|---------------------| +| Backend | AWS X-Ray | Any OTel-compatible backend | +| SDK | AWS X-Ray SDK | OpenTelemetry SDK | +| Auto-patching | Yes (boto3, requests, etc.) | Via OTel instrumentation packages | +| Annotations | `put_annotation()` | `span.set_attribute()` | +| Metadata | `put_metadata()` | `span.set_attribute()` | +| Cold start | Annotation | Span attribute | +| Context propagation | X-Ray header | W3C Trace Context | + +## API Reference + +### TracerOpenTelemetry + +```python +TracerOpenTelemetry( + mode: Literal["auto", "manual"] = "manual", + service: str | None = None, + tracer_provider: TracerProvider | None = None, + disabled: bool | None = None, +) +``` + +**Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `mode` | `"auto"` or `"manual"` | Instrumentation mode | +| `service` | `str` | Service name for spans | +| `tracer_provider` | `TracerProvider` | Custom provider (manual mode only) | +| `disabled` | `bool` | Disable tracing | + +**Methods:** + +| Method | Description | +|--------|-------------| +| `capture_lambda_handler` | Decorator for Lambda handlers | +| `capture_method` | Decorator for methods | +| `add_span(name)` | Context manager for custom spans | +| `get_current_span()` | Get the current active span | + +### Context propagation + +| Function | Description | +|----------|-------------| +| `inject_trace_context(carrier)` | Inject trace context into a dict | +| `create_span_from_context(name, carrier)` | Create span from extracted context | diff --git a/examples/tracer_otel/src/add_span.py b/examples/tracer_otel/src/add_span.py new file mode 100644 index 00000000000..1ecd9cc92c2 --- /dev/null +++ b/examples/tracer_otel/src/add_span.py @@ -0,0 +1,14 @@ +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + +tracer = TracerOpenTelemetry(mode="auto") + + +@tracer.capture_lambda_handler +def lambda_handler(event, context): + with tracer.add_span("process_order") as span: + span.set_attribute("order_id", event.get("order_id", "unknown")) + span.set_attribute("customer_tier", "premium") + # Process order logic here + result = {"order_id": event.get("order_id"), "status": "completed"} + + return {"statusCode": 200, "body": result} diff --git a/examples/tracer_otel/src/capture_method.py b/examples/tracer_otel/src/capture_method.py new file mode 100644 index 00000000000..8dc4a1f8f39 --- /dev/null +++ b/examples/tracer_otel/src/capture_method.py @@ -0,0 +1,14 @@ +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + +tracer = TracerOpenTelemetry(mode="auto") + + +@tracer.capture_method +def process_payment(payment_id: str) -> dict: + return {"payment_id": payment_id, "status": "processed"} + + +@tracer.capture_lambda_handler +def lambda_handler(event, context): + result = process_payment(event.get("payment_id", "123")) + return {"statusCode": 200, "body": result} diff --git a/examples/tracer_otel/src/capture_method_async.py b/examples/tracer_otel/src/capture_method_async.py new file mode 100644 index 00000000000..c032e1c3b87 --- /dev/null +++ b/examples/tracer_otel/src/capture_method_async.py @@ -0,0 +1,17 @@ +import asyncio + +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + +tracer = TracerOpenTelemetry(mode="auto") + + +@tracer.capture_method +async def async_get_user(user_id: str) -> dict: + await asyncio.sleep(0.1) # Simulate async I/O + return {"user_id": user_id, "name": "John Doe"} + + +@tracer.capture_lambda_handler +def lambda_handler(event, context): + user = asyncio.run(async_get_user(event.get("user_id", "123"))) + return {"statusCode": 200, "body": user} diff --git a/examples/tracer_otel/src/disable_capture_response.py b/examples/tracer_otel/src/disable_capture_response.py new file mode 100644 index 00000000000..9edb6211f70 --- /dev/null +++ b/examples/tracer_otel/src/disable_capture_response.py @@ -0,0 +1,9 @@ +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + +tracer = TracerOpenTelemetry(mode="auto") + + +@tracer.capture_lambda_handler(capture_response=False) +def lambda_handler(event, context): + # Response won't be captured in span attributes + return {"statusCode": 200, "body": "sensitive data"} diff --git a/examples/tracer_otel/src/getting_started_auto_mode.py b/examples/tracer_otel/src/getting_started_auto_mode.py new file mode 100644 index 00000000000..71e0459464e --- /dev/null +++ b/examples/tracer_otel/src/getting_started_auto_mode.py @@ -0,0 +1,8 @@ +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + +tracer = TracerOpenTelemetry(mode="auto") + + +@tracer.capture_lambda_handler +def lambda_handler(event, context): + return {"statusCode": 200, "body": "Hello, World!"} diff --git a/examples/tracer_otel/src/getting_started_manual_mode.py b/examples/tracer_otel/src/getting_started_manual_mode.py new file mode 100644 index 00000000000..e47f88606fa --- /dev/null +++ b/examples/tracer_otel/src/getting_started_manual_mode.py @@ -0,0 +1,16 @@ +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + +# Configure custom TracerProvider with OTLP exporter +provider = TracerProvider() +provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider, service="my-service") + + +@tracer.capture_lambda_handler +def lambda_handler(event, context): + return {"statusCode": 200, "body": "Hello, World!"} diff --git a/examples/tracer_otel/src/sqs_propagation_consumer.py b/examples/tracer_otel/src/sqs_propagation_consumer.py new file mode 100644 index 00000000000..f3b0c3e6fdd --- /dev/null +++ b/examples/tracer_otel/src/sqs_propagation_consumer.py @@ -0,0 +1,20 @@ +import json + +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry +from aws_lambda_powertools.tracing.otel.propagation import create_span_from_context + +tracer = TracerOpenTelemetry(mode="auto") + + +@tracer.capture_lambda_handler +def lambda_handler(event, context): + """Consumer: Extract trace context from SQS message.""" + for record in event.get("Records", []): + message = json.loads(record["body"]) + + # Continue the trace from the producer + with create_span_from_context("process_order", message) as span: + span.set_attribute("order_id", message.get("order_id")) + # Process the order + + return {"statusCode": 200} diff --git a/examples/tracer_otel/src/sqs_propagation_producer.py b/examples/tracer_otel/src/sqs_propagation_producer.py new file mode 100644 index 00000000000..da32411f007 --- /dev/null +++ b/examples/tracer_otel/src/sqs_propagation_producer.py @@ -0,0 +1,19 @@ +import json + +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry +from aws_lambda_powertools.tracing.otel.propagation import inject_trace_context + +tracer = TracerOpenTelemetry(mode="auto") + + +@tracer.capture_lambda_handler +def lambda_handler(event, context): + """Producer: Inject trace context into SQS message.""" + message = {"order_id": "12345", "amount": 99.99} + + # Inject trace context for downstream consumers + message_with_context = inject_trace_context(message) + + # Send to SQS with: sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(message_with_context)) + + return {"statusCode": 200, "body": json.dumps(message_with_context)} diff --git a/mkdocs.yml b/mkdocs.yml index db49e9e45f7..9e4ce9322ed 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,7 +13,9 @@ nav: - Tutorial: tutorial/index.md - Workshop 🆕: https://s12d.com/powertools-for-aws-lambda-workshop" target="_blank - Features: - - core/tracer.md + - Tracer: + - X-Ray SDK: core/tracer.md + - OpenTelemetry: core/tracer-otel.md - core/logger.md - Metrics: - core/metrics/index.md diff --git a/poetry.lock b/poetry.lock index 65844122bfb..e1bf9781f63 100644 --- a/poetry.lock +++ b/poetry.lock @@ -11,7 +11,7 @@ files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, ] -markers = {main = "extra == \"all\" or extra == \"parser\""} +markers = {main = "extra == \"parser\" or extra == \"all\""} [[package]] name = "anyio" @@ -325,7 +325,7 @@ description = "The AWS X-Ray SDK for Python (the SDK) enables Python developers optional = true python-versions = ">=3.7" groups = ["main"] -markers = "extra == \"all\" or extra == \"tracer\"" +markers = "extra == \"tracer\" or extra == \"all\"" files = [ {file = "aws_xray_sdk-2.15.0-py2.py3-none-any.whl", hash = "sha256:422d62ad7d52e373eebb90b642eb1bb24657afe03b22a8df4a8b2e5108e278a3"}, {file = "aws_xray_sdk-2.15.0.tar.gz", hash = "sha256:794381b96e835314345068ae1dd3b9120bd8b4e21295066c37e8814dbb341365"}, @@ -1821,7 +1821,7 @@ description = "Fastest Python implementation of JSON schema" optional = true python-versions = "*" groups = ["main"] -markers = "extra == \"all\" or extra == \"validation\"" +markers = "extra == \"validation\" or extra == \"all\"" files = [ {file = "fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463"}, {file = "fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de"}, @@ -1894,6 +1894,25 @@ gitdb = ">=4.0.1,<5" doc = ["sphinx (>=7.1.2,<7.2)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] +[[package]] +name = "googleapis-common-protos" +version = "1.72.0" +description = "Common protobufs used in Google APIs" +optional = true +python-versions = ">=3.7" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038"}, + {file = "googleapis_common_protos-1.72.0.tar.gz", hash = "sha256:e55a601c1b32b52d7a3e65f43563e2aa61bcd737998ee672ac9b951cd49319f5"}, +] + +[package.dependencies] +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[package.extras] +grpc = ["grpcio (>=1.44.0,<2.0.0)"] + [[package]] name = "griffe" version = "1.15.0" @@ -2131,7 +2150,7 @@ description = "Read metadata from Python packages" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"datadog\"" +markers = "extra == \"datadog\" or extra == \"otel\"" files = [ {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, @@ -3217,7 +3236,7 @@ description = "OpenTelemetry Python API" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"datadog\"" +markers = "extra == \"datadog\" or extra == \"otel\"" files = [ {file = "opentelemetry_api-1.39.1-py3-none-any.whl", hash = "sha256:2edd8463432a7f8443edce90972169b195e7d6a05500cd29e6d13898187c9950"}, {file = "opentelemetry_api-1.39.1.tar.gz", hash = "sha256:fbde8c80e1b937a2c61f20347e91c0c18a1940cecf012d62e65a7caf08967c9c"}, @@ -3227,6 +3246,114 @@ files = [ importlib-metadata = ">=6.0,<8.8.0" typing-extensions = ">=4.5.0" +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.39.1" +description = "OpenTelemetry Protobuf encoding" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_exporter_otlp_proto_common-1.39.1-py3-none-any.whl", hash = "sha256:08f8a5862d64cc3435105686d0216c1365dc5701f86844a8cd56597d0c764fde"}, + {file = "opentelemetry_exporter_otlp_proto_common-1.39.1.tar.gz", hash = "sha256:763370d4737a59741c89a67b50f9e39271639ee4afc999dadfe768541c027464"}, +] + +[package.dependencies] +opentelemetry-proto = "1.39.1" + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.39.1" +description = "OpenTelemetry Collector Protobuf over HTTP Exporter" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985"}, + {file = "opentelemetry_exporter_otlp_proto_http-1.39.1.tar.gz", hash = "sha256:31bdab9745c709ce90a49a0624c2bd445d31a28ba34275951a6a362d16a0b9cb"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.52,<2.0" +opentelemetry-api = ">=1.15,<2.0" +opentelemetry-exporter-otlp-proto-common = "1.39.1" +opentelemetry-proto = "1.39.1" +opentelemetry-sdk = ">=1.39.1,<1.40.0" +requests = ">=2.7,<3.0" +typing-extensions = ">=4.5.0" + +[package.extras] +gcp-auth = ["opentelemetry-exporter-credential-provider-gcp (>=0.59b0)"] + +[[package]] +name = "opentelemetry-propagator-aws-xray" +version = "1.0.2" +description = "AWS X-Ray Propagator for OpenTelemetry" +optional = true +python-versions = ">=3.8" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_propagator_aws_xray-1.0.2-py3-none-any.whl", hash = "sha256:1c99181ee228e99bddb638a0c911a297fa21f1c3a0af951f841e79919b5f1934"}, + {file = "opentelemetry_propagator_aws_xray-1.0.2.tar.gz", hash = "sha256:6b2cee5479d2ef0172307b66ed2ed151f598a0fd29b3c01133ac87ca06326260"}, +] + +[package.dependencies] +opentelemetry-api = ">=1.12,<2.0" + +[[package]] +name = "opentelemetry-proto" +version = "1.39.1" +description = "OpenTelemetry Python Proto" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_proto-1.39.1-py3-none-any.whl", hash = "sha256:22cdc78efd3b3765d09e68bfbd010d4fc254c9818afd0b6b423387d9dee46007"}, + {file = "opentelemetry_proto-1.39.1.tar.gz", hash = "sha256:6c8e05144fc0d3ed4d22c2289c6b126e03bcd0e6a7da0f16cedd2e1c2772e2c8"}, +] + +[package.dependencies] +protobuf = ">=5.0,<7.0" + +[[package]] +name = "opentelemetry-sdk" +version = "1.39.1" +description = "OpenTelemetry Python SDK" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_sdk-1.39.1-py3-none-any.whl", hash = "sha256:4d5482c478513ecb0a5d938dcc61394e647066e0cc2676bee9f3af3f3f45f01c"}, + {file = "opentelemetry_sdk-1.39.1.tar.gz", hash = "sha256:cf4d4563caf7bff906c9f7967e2be22d0d6b349b908be0d90fb21c8e9c995cc6"}, +] + +[package.dependencies] +opentelemetry-api = "1.39.1" +opentelemetry-semantic-conventions = "0.60b1" +typing-extensions = ">=4.5.0" + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.60b1" +description = "OpenTelemetry Semantic Conventions" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"otel\"" +files = [ + {file = "opentelemetry_semantic_conventions-0.60b1-py3-none-any.whl", hash = "sha256:9fa8c8b0c110da289809292b0591220d3a7b53c1526a23021e977d68597893fb"}, + {file = "opentelemetry_semantic_conventions-0.60b1.tar.gz", hash = "sha256:87c228b5a0669b748c76d76df6c364c369c28f1c465e50f661e39737e84bc953"}, +] + +[package.dependencies] +opentelemetry-api = "1.39.1" +typing-extensions = ">=4.5.0" + [[package]] name = "packaging" version = "25.0" @@ -3381,7 +3508,7 @@ files = [ {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, ] -markers = {main = "extra == \"all\" or extra == \"parser\""} +markers = {main = "extra == \"parser\" or extra == \"all\""} [package.dependencies] annotated-types = ">=0.6.0" @@ -3523,7 +3650,7 @@ files = [ {file = "pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51"}, {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, ] -markers = {main = "extra == \"all\" or extra == \"parser\""} +markers = {main = "extra == \"parser\" or extra == \"all\""} [package.dependencies] typing-extensions = ">=4.14.1" @@ -4751,7 +4878,7 @@ files = [ {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, ] -markers = {main = "extra == \"all\" or extra == \"parser\""} +markers = {main = "extra == \"parser\" or extra == \"all\""} [package.dependencies] typing-extensions = ">=4.12.0" @@ -5078,7 +5205,7 @@ files = [ {file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"}, {file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"}, ] -markers = {main = "extra == \"all\" or extra == \"datamasking\" or extra == \"tracer\" or extra == \"datadog\""} +markers = {main = "extra == \"tracer\" or extra == \"all\" or extra == \"datamasking\" or extra == \"datadog\""} [[package]] name = "xenon" @@ -5104,7 +5231,7 @@ description = "Backport of pathlib-compatible object wrapper for zip files" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "extra == \"datadog\"" +markers = "extra == \"datadog\" or extra == \"otel\"" files = [ {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, @@ -5125,6 +5252,7 @@ datadog = ["datadog-lambda"] datamasking = ["aws-encryption-sdk", "jsonpath-ng"] kafka-consumer-avro = ["avro"] kafka-consumer-protobuf = ["protobuf"] +otel = ["opentelemetry-api", "opentelemetry-exporter-otlp-proto-http", "opentelemetry-propagator-aws-xray", "opentelemetry-sdk"] parser = ["pydantic"] redis = ["redis"] tracer = ["aws-xray-sdk"] @@ -5134,4 +5262,4 @@ valkey = ["valkey-glide"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4.0.0" -content-hash = "2ffce4ef25a194c40bedb86374446665063edf62b92120f09e4fa3c70dd9cdb0" +content-hash = "9ceed11ff89e599f74c9c9e3be7582dabc75ab0c23878245a704b25a546b75d0" diff --git a/pyproject.toml b/pyproject.toml index 2161147f66b..02d4d0cb697 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,10 @@ jsonpath-ng = { version = "^1.6.0", optional = true } datadog-lambda = { version = ">=8.114.0,<9.0.0", optional = true } avro = { version = "^1.12.0", optional = true } protobuf = {version = "^6.30.2", optional = true } +opentelemetry-api = { version = "^1.39.1", optional = true } +opentelemetry-sdk = { version = "^1.39.1", optional = true } +opentelemetry-exporter-otlp-proto-http = { version = "^1.39.1", optional = true } +opentelemetry-propagator-aws-xray = { version = "^1.0.2", optional = true } [tool.poetry.extras] parser = ["pydantic"] @@ -78,6 +82,7 @@ datadog = ["datadog-lambda"] datamasking = ["aws-encryption-sdk", "jsonpath-ng"] kafka-consumer-avro = ["avro"] kafka-consumer-protobuf = ["protobuf"] +otel = ["opentelemetry-api", "opentelemetry-sdk", "opentelemetry-exporter-otlp-proto-http", "opentelemetry-propagator-aws-xray"] [tool.poetry.group.dev.dependencies] coverage = { extras = ["toml"], version = "^7.6" } diff --git a/tests/e2e/tracer_otel/__init__.py b/tests/e2e/tracer_otel/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/e2e/tracer_otel/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/e2e/tracer_otel/conftest.py b/tests/e2e/tracer_otel/conftest.py new file mode 100644 index 00000000000..3cbeb75f357 --- /dev/null +++ b/tests/e2e/tracer_otel/conftest.py @@ -0,0 +1,10 @@ +"""E2E test fixtures for OpenTelemetry Tracer.""" + +import pytest + + +@pytest.fixture +def infrastructure(): + """Fixture to deploy and teardown test infrastructure.""" + # Infrastructure deployment handled by CDK in infrastructure.py + pass diff --git a/tests/e2e/tracer_otel/handlers/handler_auto_mode.py b/tests/e2e/tracer_otel/handlers/handler_auto_mode.py new file mode 100644 index 00000000000..cb84530653d --- /dev/null +++ b/tests/e2e/tracer_otel/handlers/handler_auto_mode.py @@ -0,0 +1,19 @@ +"""Lambda handler for E2E tests - Auto mode.""" + +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + +tracer = TracerOpenTelemetry(mode="auto") + + +@tracer.capture_method +def process_data(data: dict) -> dict: + return {"processed": True, "input": data} + + +@tracer.capture_lambda_handler +def handler(event, context): + with tracer.add_span("business_logic") as span: + span.set_attribute("event_size", len(str(event))) + result = process_data(event) + + return {"statusCode": 200, "body": result} diff --git a/tests/e2e/tracer_otel/handlers/handler_manual_mode.py b/tests/e2e/tracer_otel/handlers/handler_manual_mode.py new file mode 100644 index 00000000000..a78cc6c29e4 --- /dev/null +++ b/tests/e2e/tracer_otel/handlers/handler_manual_mode.py @@ -0,0 +1,27 @@ +"""Lambda handler for E2E tests - Manual mode.""" + +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + +# Configure TracerProvider +provider = TracerProvider() +provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + +tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider) + + +@tracer.capture_method +def process_data(data: dict) -> dict: + return {"processed": True, "input": data} + + +@tracer.capture_lambda_handler +def handler(event, context): + with tracer.add_span("business_logic") as span: + span.set_attribute("event_size", len(str(event))) + result = process_data(event) + + return {"statusCode": 200, "body": result} diff --git a/tests/e2e/tracer_otel/infrastructure.py b/tests/e2e/tracer_otel/infrastructure.py new file mode 100644 index 00000000000..f1f8ae48dce --- /dev/null +++ b/tests/e2e/tracer_otel/infrastructure.py @@ -0,0 +1,18 @@ +"""CDK infrastructure for OpenTelemetry Tracer E2E tests.""" + +from __future__ import annotations + +from tests.e2e.utils.infrastructure import BaseInfrastructure + + +class TracerOtelStack(BaseInfrastructure): + """Infrastructure stack for OpenTelemetry Tracer E2E tests. + + Deploys Lambda functions with ADOT Layer for testing auto and manual modes. + """ + + # ADOT Lambda Layer ARN (update version as needed) + ADOT_LAYER_ARN = "arn:aws:lambda:{region}:901920570463:layer:aws-otel-python-amd64-ver-1-24-0:1" + + def create_resources(self) -> None: + self.create_lambda_functions() diff --git a/tests/e2e/tracer_otel/test_tracer_otel.py b/tests/e2e/tracer_otel/test_tracer_otel.py new file mode 100644 index 00000000000..57ff5309163 --- /dev/null +++ b/tests/e2e/tracer_otel/test_tracer_otel.py @@ -0,0 +1,38 @@ +"""E2E tests for OpenTelemetry Tracer. + +Note: These tests require ADOT Lambda Layer and are not run in CI due to slow feedback loop. +Run manually with: pytest tests/e2e/tracer_otel/ -v +""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.skip(reason="E2E tests not run automatically - slow feedback loop") +class TestAutoMode: + """E2E tests for auto mode with ADOT Layer.""" + + def test_handler_creates_spans(self, infrastructure): + """Handler should create spans when using auto mode with ADOT.""" + # Deploy Lambda with ADOT Layer + # Invoke Lambda + # Verify spans exported to collector + pass + + def test_cold_start_attribute(self, infrastructure): + """Should set faas.coldstart attribute correctly.""" + pass + + +@pytest.mark.skip(reason="E2E tests not run automatically - slow feedback loop") +class TestManualMode: + """E2E tests for manual mode.""" + + def test_handler_creates_spans(self, infrastructure): + """Handler should create spans when using manual mode.""" + pass + + def test_custom_exporter(self, infrastructure): + """Should export spans to custom endpoint.""" + pass diff --git a/tests/functional/tracing/__init__.py b/tests/functional/tracing/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/functional/tracing/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/functional/tracing/otel/__init__.py b/tests/functional/tracing/otel/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/functional/tracing/otel/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/functional/tracing/otel/test_tracer_otel.py b/tests/functional/tracing/otel/test_tracer_otel.py new file mode 100644 index 00000000000..707a4ca02a8 --- /dev/null +++ b/tests/functional/tracing/otel/test_tracer_otel.py @@ -0,0 +1,232 @@ +"""Functional tests for OpenTelemetry Tracer with real OTel SDK.""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("opentelemetry") + +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + +@pytest.fixture +def in_memory_exporter(): + """Create TracerProvider with in-memory exporter.""" + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + return provider, exporter + + +# Span hierarchy tests + + +def test_handler_creates_root_span(in_memory_exporter): + """Handler decorator should create root span.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + from aws_lambda_powertools.tracing.otel import tracer as tracer_module + + tracer_module.is_cold_start = True + + provider, exporter = in_memory_exporter + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider) + + @tracer.capture_lambda_handler + def handler(event, context): + return {"statusCode": 200} + + class MockContext: + function_name = "test_function" + + handler({}, MockContext()) + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "handler" + + +def test_method_creates_child_span(in_memory_exporter): + """Method decorator should create child span.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + from aws_lambda_powertools.tracing.otel import tracer as tracer_module + + tracer_module.is_cold_start = True + + provider, exporter = in_memory_exporter + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider) + + @tracer.capture_method + def process_data(): + return "processed" + + @tracer.capture_lambda_handler + def handler(event, context): + return process_data() + + class MockContext: + function_name = "test_function" + + handler({}, MockContext()) + + spans = exporter.get_finished_spans() + assert len(spans) == 2 + + +def test_add_span_creates_child(in_memory_exporter): + """add_span should create child span.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + from aws_lambda_powertools.tracing.otel import tracer as tracer_module + + tracer_module.is_cold_start = True + + provider, exporter = in_memory_exporter + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider) + + @tracer.capture_lambda_handler + def handler(event, context): + with tracer.add_span("custom_operation") as span: + span.set_attribute("custom_key", "custom_value") + return {"statusCode": 200} + + class MockContext: + function_name = "test_function" + + handler({}, MockContext()) + + spans = exporter.get_finished_spans() + assert len(spans) == 2 + custom_span = next(s for s in spans if s.name == "custom_operation") + assert custom_span.attributes.get("custom_key") == "custom_value" + + +# Span attributes tests + + +def test_cold_start_attribute(in_memory_exporter): + """Should set faas.coldstart attribute.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + from aws_lambda_powertools.tracing.otel import tracer as tracer_module + + tracer_module.is_cold_start = True + + provider, exporter = in_memory_exporter + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider, service="test-service") + + @tracer.capture_lambda_handler + def handler(event, context): + return {"statusCode": 200} + + class MockContext: + function_name = "test_function" + + handler({}, MockContext()) + + spans = exporter.get_finished_spans() + assert spans[0].attributes.get("faas.coldstart") is True + assert spans[0].attributes.get("service.name") == "test-service" + + +def test_response_captured_as_attribute(in_memory_exporter): + """Should capture response as span attribute.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + from aws_lambda_powertools.tracing.otel import tracer as tracer_module + + tracer_module.is_cold_start = True + + provider, exporter = in_memory_exporter + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider) + + @tracer.capture_lambda_handler(capture_response=True) + def handler(event, context): + return {"statusCode": 200} + + class MockContext: + function_name = "test_function" + + handler({}, MockContext()) + + spans = exporter.get_finished_spans() + assert "handler.response" in spans[0].attributes + + +# Error handling tests + + +def test_exception_recorded_in_span(in_memory_exporter): + """Should record exception in span.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + from aws_lambda_powertools.tracing.otel import tracer as tracer_module + + tracer_module.is_cold_start = True + + provider, exporter = in_memory_exporter + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider) + + @tracer.capture_lambda_handler + def handler(event, context): + raise ValueError("test error") + + class MockContext: + function_name = "test_function" + + with pytest.raises(ValueError): + handler({}, MockContext()) + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + assert len(spans[0].events) > 0 # Exception event recorded + + +# Propagation tests + + +def test_inject_trace_context(in_memory_exporter): + """inject_trace_context should add trace headers to carrier.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + from aws_lambda_powertools.tracing.otel import tracer as tracer_module + from aws_lambda_powertools.tracing.otel.propagation import inject_trace_context + + tracer_module.is_cold_start = True + + provider, exporter = in_memory_exporter + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=provider) + + @tracer.capture_lambda_handler + def handler(event, context): + carrier = {"data": "payload"} + result = inject_trace_context(carrier) + return result + + class MockContext: + function_name = "test_function" + + result = handler({}, MockContext()) + + assert "data" in result + # traceparent header should be injected + assert "traceparent" in result or len(result) >= 1 + + +def test_create_span_from_context(in_memory_exporter): + """create_span_from_context should create span with extracted context.""" + from opentelemetry import trace + + from aws_lambda_powertools.tracing.otel.propagation import create_span_from_context + + provider, exporter = in_memory_exporter + + # Set the provider as global so create_span_from_context uses it + trace.set_tracer_provider(provider) + + # Carrier with no context - should still create span + carrier = {"data": "payload"} + + with create_span_from_context("process_message", carrier) as span: + span.set_attribute("test_key", "test_value") + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].name == "process_message" + assert spans[0].attributes.get("test_key") == "test_value" diff --git a/tests/unit/tracing/__init__.py b/tests/unit/tracing/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/unit/tracing/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit/tracing/otel/__init__.py b/tests/unit/tracing/otel/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/unit/tracing/otel/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/unit/tracing/otel/test_tracer_otel.py b/tests/unit/tracing/otel/test_tracer_otel.py new file mode 100644 index 00000000000..e22c732f8a8 --- /dev/null +++ b/tests/unit/tracing/otel/test_tracer_otel.py @@ -0,0 +1,546 @@ +"""Unit tests for OpenTelemetry Tracer.""" + +from __future__ import annotations + +from unittest import mock + +import pytest + + +@pytest.fixture +def reset_cold_start(): + """Reset cold start state before each test.""" + from aws_lambda_powertools.tracing.otel import tracer as tracer_module + + tracer_module.is_cold_start = True + yield + tracer_module.is_cold_start = True + + +@pytest.fixture +def mock_tracer_provider(): + """Create a mock TracerProvider.""" + mock_span = mock.MagicMock() + mock_span.__enter__ = mock.MagicMock(return_value=mock_span) + mock_span.__exit__ = mock.MagicMock(return_value=False) + + mock_tracer = mock.MagicMock() + mock_tracer.start_as_current_span.return_value = mock_span + + mock_provider = mock.MagicMock() + mock_provider.get_tracer.return_value = mock_tracer + + return mock_provider, mock_tracer, mock_span + + +# Init tests + + +def test_auto_mode_raises_if_tracer_provider_given(): + """Auto mode should raise error if tracer_provider is provided.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider = mock.MagicMock() + + with pytest.raises(ValueError, match="tracer_provider cannot be provided in auto mode"): + TracerOpenTelemetry(mode="auto", tracer_provider=mock_provider) + + +def test_manual_mode_uses_provided_tracer_provider(mock_tracer_provider): + """Manual mode should use provided TracerProvider.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, _ = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + assert tracer.provider == mock_provider + + +def test_manual_mode_creates_default_provider_if_none_given(): + """Manual mode should create default TracerProvider if none provided.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + with mock.patch("opentelemetry.sdk.trace.TracerProvider") as mock_sdk: + mock_sdk.return_value = mock.MagicMock() + TracerOpenTelemetry(mode="manual") + mock_sdk.assert_called_once() + + +def test_disabled_mode(): + """Disabled tracer should not create provider.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + assert tracer.disabled is True + assert tracer._tracer_provider is None + + +def test_service_from_env_var(monkeypatch): + """Service name should fall back to environment variable.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + monkeypatch.setenv("POWERTOOLS_SERVICE_NAME", "test-service") + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + assert tracer.service == "test-service" + + +def test_disabled_from_env_var(monkeypatch): + """Disabled should fall back to environment variable.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + monkeypatch.setenv("POWERTOOLS_TRACE_DISABLED", "true") + + tracer = TracerOpenTelemetry(mode="manual") + + assert tracer.disabled is True + + +# capture_lambda_handler tests + + +def test_creates_span_for_handler(mock_tracer_provider, reset_cold_start): + """Should create span for Lambda handler.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, mock_tracer, _ = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_lambda_handler + def handler(event, context): + return {"statusCode": 200} + + result = handler({}, mock.MagicMock()) + + assert result == {"statusCode": 200} + mock_tracer.start_as_current_span.assert_called_once() + + +def test_adds_cold_start_attribute(mock_tracer_provider, reset_cold_start): + """Should add faas.coldstart attribute.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_lambda_handler + def handler(event, context): + return {"statusCode": 200} + + handler({}, mock.MagicMock()) + + mock_span.set_attribute.assert_any_call("faas.coldstart", True) + + +def test_cold_start_false_on_second_invocation(mock_tracer_provider, reset_cold_start): + """Cold start should be False on second invocation.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_lambda_handler + def handler(event, context): + return {"statusCode": 200} + + handler({}, mock.MagicMock()) + mock_span.reset_mock() + handler({}, mock.MagicMock()) + + mock_span.set_attribute.assert_any_call("faas.coldstart", False) + + +def test_handler_disabled_passes_through(reset_cold_start): + """Disabled tracer should pass through without tracing.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + @tracer.capture_lambda_handler + def handler(event, context): + return {"statusCode": 200} + + result = handler({}, mock.MagicMock()) + + assert result == {"statusCode": 200} + + +def test_captures_exception(mock_tracer_provider, reset_cold_start): + """Should record exception when handler raises.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_lambda_handler + def handler(event, context): + raise ValueError("test error") + + with pytest.raises(ValueError): + handler({}, mock.MagicMock()) + + mock_span.record_exception.assert_called_once() + + +# capture_method tests + + +def test_sync_function(mock_tracer_provider): + """Should trace sync function.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, mock_tracer, _ = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_method + def my_function(): + return "result" + + result = my_function() + + assert result == "result" + mock_tracer.start_as_current_span.assert_called_once() + + +def test_async_function(mock_tracer_provider): + """Should trace async function.""" + import asyncio + + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, mock_tracer, _ = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_method + async def my_async_function(): + return "async result" + + result = asyncio.run(my_async_function()) + + assert result == "async result" + mock_tracer.start_as_current_span.assert_called_once() + + +def test_generator_function(mock_tracer_provider): + """Should trace generator function.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, mock_tracer, _ = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_method + def my_generator(): + yield 1 + yield 2 + + result = list(my_generator()) + + assert result == [1, 2] + mock_tracer.start_as_current_span.assert_called_once() + + +def test_method_disabled_passes_through(): + """Disabled tracer should pass through without tracing.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + @tracer.capture_method + def my_function(): + return "result" + + result = my_function() + + assert result == "result" + + +# add_span tests + + +def test_creates_child_span(mock_tracer_provider): + """Should create child span.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, mock_tracer, _ = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + with tracer.add_span("test_span") as span: + span.set_attribute("key", "value") + + mock_tracer.start_as_current_span.assert_called_once_with( + name="test_span", + record_exception=True, + set_status_on_exception=True, + ) + + +def test_add_span_disabled_yields_none(): + """Disabled tracer should yield None.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + with tracer.add_span("test_span") as span: + assert span is None + + +# get_current_span tests + + +def test_get_current_span_returns_none_when_disabled(): + """Should return None when disabled.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + assert tracer.get_current_span() is None + + +def test_get_current_span_returns_current_span(mock_tracer_provider): + """Should return current span from OTel context.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, _ = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + with mock.patch("opentelemetry.trace.get_current_span") as mock_get: + mock_get.return_value = mock.MagicMock() + span = tracer.get_current_span() + + mock_get.assert_called_once() + assert span is not None + + +# Cold start with provisioned concurrency + + +def test_cold_start_false_with_provisioned_concurrency(monkeypatch, mock_tracer_provider, reset_cold_start): + """Cold start should be False with provisioned concurrency.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + monkeypatch.setenv("AWS_LAMBDA_INITIALIZATION_TYPE", "provisioned-concurrency") + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_lambda_handler + def handler(event, context): + return {"statusCode": 200} + + handler({}, mock.MagicMock()) + + mock_span.set_attribute.assert_any_call("faas.coldstart", False) + + +# Auto mode provider + + +def test_auto_mode_uses_global_provider(): + """Auto mode should use global TracerProvider.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + with mock.patch("opentelemetry.trace.get_tracer_provider") as mock_get: + mock_provider = mock.MagicMock() + mock_get.return_value = mock_provider + + tracer = TracerOpenTelemetry(mode="auto") + provider = tracer.provider + + mock_get.assert_called_once() + assert provider == mock_provider + + +# instrument_requests + + +def test_instrument_requests_disabled(): + """instrument_requests should do nothing when disabled.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + tracer.instrument_requests() # Should not raise + + +def test_instrument_requests_import_error(mock_tracer_provider, caplog): + """instrument_requests should warn on missing package.""" + import logging + import sys + + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, _ = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + # Remove the module if it exists to simulate ImportError + original = sys.modules.get("opentelemetry.instrumentation.requests") + sys.modules["opentelemetry.instrumentation.requests"] = None + + try: + with caplog.at_level(logging.WARNING): + tracer.instrument_requests() + finally: + if original: + sys.modules["opentelemetry.instrumentation.requests"] = original + else: + sys.modules.pop("opentelemetry.instrumentation.requests", None) + + +# capture_method with capture_response=False + + +def test_capture_method_no_response(mock_tracer_provider): + """capture_method should not capture response when disabled.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_method(capture_response=False) + def my_function(): + return "result" + + result = my_function() + + assert result == "result" + # Should not have response attribute set + response_calls = [c for c in mock_span.set_attribute.call_args_list if "response" in str(c)] + assert len(response_calls) == 0 + + +# capture_lambda_handler with capture_error=False + + +def test_handler_no_error_capture(mock_tracer_provider, reset_cold_start): + """Handler should not record exception when capture_error=False.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_lambda_handler(capture_error=False) + def handler(event, context): + raise ValueError("test error") + + with pytest.raises(ValueError): + handler({}, mock.MagicMock()) + + mock_span.record_exception.assert_not_called() + + +# Generator disabled pass-through + + +def test_generator_disabled_passes_through(): + """Disabled tracer should pass through generator without tracing.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + @tracer.capture_method + def my_generator(): + yield 1 + yield 2 + + result = list(my_generator()) + + assert result == [1, 2] + + +# Service from Lambda function name + + +def test_service_from_lambda_function_name(monkeypatch): + """Service name should fall back to Lambda function name.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + monkeypatch.delenv("POWERTOOLS_SERVICE_NAME", raising=False) + monkeypatch.setenv("AWS_LAMBDA_FUNCTION_NAME", "my-lambda-function") + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + assert tracer.service == "my-lambda-function" + + +# Async disabled pass-through + + +def test_async_disabled_passes_through(): + """Disabled tracer should pass through async function without tracing.""" + import asyncio + + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + tracer = TracerOpenTelemetry(mode="manual", disabled=True) + + @tracer.capture_method + async def my_async_function(): + return "async result" + + result = asyncio.run(my_async_function()) + + assert result == "async result" + + +# Method exception capture + + +def test_method_captures_exception(mock_tracer_provider): + """capture_method should record exception when method raises.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_method + def my_function(): + raise ValueError("test error") + + with pytest.raises(ValueError): + my_function() + + mock_span.record_exception.assert_called_once() + + +# Async exception capture + + +def test_async_captures_exception(mock_tracer_provider): + """capture_method should record exception when async method raises.""" + import asyncio + + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_method + async def my_async_function(): + raise ValueError("async error") + + with pytest.raises(ValueError): + asyncio.run(my_async_function()) + + mock_span.record_exception.assert_called_once() + + +# Generator exception capture + + +def test_generator_captures_exception(mock_tracer_provider): + """capture_method should record exception when generator raises.""" + from aws_lambda_powertools.tracing.otel import TracerOpenTelemetry + + mock_provider, _, mock_span = mock_tracer_provider + tracer = TracerOpenTelemetry(mode="manual", tracer_provider=mock_provider) + + @tracer.capture_method + def my_generator(): + yield 1 + raise ValueError("generator error") + + with pytest.raises(ValueError): + list(my_generator()) + + mock_span.record_exception.assert_called_once()