diff --git a/CHANGELOG.md b/CHANGELOG.md index d3d46182ea..73304820fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3936](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3936)) - `opentelemetry-instrumentation-aiohttp-client`: Update instrumentor to respect suppressing http instrumentation ([#3957](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3957)) +- `opentelemetry-instrumentation-aws-lambda`: Fix improper invocation `Span` name and kind. + ([#3966](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3966)) ## Version 1.38.0/0.59b0 (2025-10-16) diff --git a/instrumentation/opentelemetry-instrumentation-aws-lambda/src/opentelemetry/instrumentation/aws_lambda/__init__.py b/instrumentation/opentelemetry-instrumentation-aws-lambda/src/opentelemetry/instrumentation/aws_lambda/__init__.py index 9b450cdf21..e666250c63 100644 --- a/instrumentation/opentelemetry-instrumentation-aws-lambda/src/opentelemetry/instrumentation/aws_lambda/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-aws-lambda/src/opentelemetry/instrumentation/aws_lambda/__init__.py @@ -69,11 +69,13 @@ def custom_event_context_extractor(lambda_event): --- """ +from __future__ import annotations + import logging import os import time from importlib import import_module -from typing import Any, Callable, Collection +from typing import TYPE_CHECKING, Any, Callable, Collection from urllib.parse import urlencode from wrapt import wrap_function_wrapper @@ -123,6 +125,29 @@ def custom_event_context_extractor(lambda_event): "OTEL_INSTRUMENTATION_AWS_LAMBDA_FLUSH_TIMEOUT" ) +if TYPE_CHECKING: + import typing + + class LambdaContext(typing.Protocol): + """Type definition for AWS Lambda context object. + + This Protocol defines the interface for the context object passed to Lambda + function handlers, providing information about the invocation, function, and + execution environment. + + See Also: + AWS Lambda Context Object documentation: + https://docs.aws.amazon.com/lambda/latest/dg/python-context.html + """ + + function_name: str + function_version: str + invoked_function_arn: str + memory_limit_in_mb: int + aws_request_id: str + log_group_name: str + log_stream_name: str + def _default_event_context_extractor(lambda_event: Any) -> Context: """Default way of extracting the context from the Lambda Event. @@ -264,6 +289,50 @@ def _set_api_gateway_v2_proxy_attributes( return span +def _get_lambda_context_attributes( + lambda_context: LambdaContext, +) -> dict[str, str]: + """Extracts OpenTelemetry span attributes from AWS Lambda context. + + Extract FaaS specific attributes from the AWS Lambda context + according to OpenTelemetry semantic conventions for FaaS & AWS Lambda. + + Args: + lambda_context: The AWS Lambda context object. + + Returns: + A dictionary mapping of OpenTelemetry attribute names to their values. + """ + function_arn_parts: list[str] = lambda_context.invoked_function_arn.split( + ":" + ) + # NOTE: `cloud.account.id` can be parsed from the ARN as the fifth item when splitting on `:` + # + # See more: + # https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/aws-lambda.md#all-triggers + aws_account_id: str = function_arn_parts[4] + # NOTE: The unmodified function ARN may contain an alias extension e.g. + # `arn:aws:lambda:region:account:function:name:alias`. We can ensure + # the alias extension is not included in the `cloud.resource_id` by keeping + # only the first 7 parts of the original ARN. + # + # See more: + # https://docs.aws.amazon.com/lambda/latest/dg/python-context.html + formatted_function_arn: str = ":".join(function_arn_parts[:7]) + + # NOTE: The specs mention an exception here, allowing the + # `SpanAttributes.CLOUD_RESOURCE_ID` attribute to be set as a span + # attribute instead of a resource attribute. + # + # See more: + # https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/aws-lambda.md#resource-detector + return { + CLOUD_ACCOUNT_ID: aws_account_id, + CLOUD_RESOURCE_ID: formatted_function_arn, + FAAS_INVOCATION_ID: lambda_context.aws_request_id, + } + + # pylint: disable=too-many-statements def _instrument( wrapped_module_name, @@ -278,38 +347,14 @@ def _instrument( def _instrumented_lambda_handler_call( # noqa pylint: disable=too-many-branches call_wrapped, instance, args, kwargs ): - orig_handler_name = ".".join( - [wrapped_module_name, wrapped_function_name] - ) - - lambda_event = args[0] + lambda_event: Any = args[0] + lambda_context: LambdaContext = args[1] parent_context = _determine_parent_context( lambda_event, event_context_extractor, ) - try: - event_source = lambda_event["Records"][0].get( - "eventSource" - ) or lambda_event["Records"][0].get("EventSource") - if event_source in { - "aws:sqs", - "aws:s3", - "aws:sns", - "aws:dynamodb", - }: - # See more: - # https://docs.aws.amazon.com/lambda/latest/dg/with-sqs.html - # https://docs.aws.amazon.com/lambda/latest/dg/with-sns.html - # https://docs.aws.amazon.com/AmazonS3/latest/userguide/notification-content-structure.html - # https://docs.aws.amazon.com/lambda/latest/dg/with-ddb.html - span_kind = SpanKind.CONSUMER - else: - span_kind = SpanKind.SERVER - except (IndexError, KeyError, TypeError): - span_kind = SpanKind.SERVER - tracer = get_tracer( __name__, __version__, @@ -320,38 +365,10 @@ def _instrumented_lambda_handler_call( # noqa pylint: disable=too-many-branches token = context_api.attach(parent_context) try: with tracer.start_as_current_span( - name=orig_handler_name, - kind=span_kind, + name=lambda_context.function_name, + kind=SpanKind.SERVER, + attributes=_get_lambda_context_attributes(lambda_context), ) as span: - if span.is_recording(): - lambda_context = args[1] - # NOTE: The specs mention an exception here, allowing the - # `SpanAttributes.CLOUD_RESOURCE_ID` attribute to be set as a span - # attribute instead of a resource attribute. - # - # See more: - # https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/aws-lambda.md#resource-detector - span.set_attribute( - CLOUD_RESOURCE_ID, - lambda_context.invoked_function_arn, - ) - span.set_attribute( - FAAS_INVOCATION_ID, - lambda_context.aws_request_id, - ) - - # NOTE: `cloud.account.id` can be parsed from the ARN as the fifth item when splitting on `:` - # - # See more: - # https://github.com/open-telemetry/semantic-conventions/blob/main/docs/faas/aws-lambda.md#all-triggers - account_id = lambda_context.invoked_function_arn.split( - ":" - )[4] - span.set_attribute( - CLOUD_ACCOUNT_ID, - account_id, - ) - exception = None result = None try: diff --git a/instrumentation/opentelemetry-instrumentation-aws-lambda/tests/test_aws_lambda_instrumentation_manual.py b/instrumentation/opentelemetry-instrumentation-aws-lambda/tests/test_aws_lambda_instrumentation_manual.py index a468cb986a..9f10a9f0fa 100644 --- a/instrumentation/opentelemetry-instrumentation-aws-lambda/tests/test_aws_lambda_instrumentation_manual.py +++ b/instrumentation/opentelemetry-instrumentation-aws-lambda/tests/test_aws_lambda_instrumentation_manual.py @@ -74,18 +74,22 @@ class MockLambdaContext: - def __init__(self, aws_request_id, invoked_function_arn): + def __init__(self, function_name, aws_request_id, invoked_function_arn): + self.function_name = function_name self.invoked_function_arn = invoked_function_arn self.aws_request_id = aws_request_id MOCK_LAMBDA_CONTEXT = MockLambdaContext( + function_name="myfunction", aws_request_id="mock_aws_request_id", invoked_function_arn="arn:aws:lambda:us-east-1:123456:function:myfunction:myalias", ) MOCK_LAMBDA_CONTEXT_ATTRIBUTES = { - CLOUD_RESOURCE_ID: MOCK_LAMBDA_CONTEXT.invoked_function_arn, + CLOUD_RESOURCE_ID: ":".join( + MOCK_LAMBDA_CONTEXT.invoked_function_arn.split(":")[:7] + ), FAAS_INVOCATION_ID: MOCK_LAMBDA_CONTEXT.aws_request_id, CLOUD_ACCOUNT_ID: MOCK_LAMBDA_CONTEXT.invoked_function_arn.split(":")[4], } @@ -115,7 +119,7 @@ def __init__(self, aws_request_id, invoked_function_arn): MOCK_W3C_BAGGAGE_VALUE = "baggage_value" -def mock_execute_lambda(event=None): +def mock_execute_lambda(event=None, context=None): """Mocks the AWS Lambda execution. NOTE: We don't use `moto`'s `mock_lambda` because we are not instrumenting @@ -127,11 +131,14 @@ def mock_execute_lambda(event=None): Args: event: The Lambda event which may or may not be used by instrumentation. + context: The AWS Lambda context to call the handler with """ module_name, handler_name = os.environ[_HANDLER].rsplit(".", 1) handler_module = import_module(module_name.replace("/", ".")) - return getattr(handler_module, handler_name)(event, MOCK_LAMBDA_CONTEXT) + return getattr(handler_module, handler_name)( + event, context or MOCK_LAMBDA_CONTEXT + ) class TestAwsLambdaInstrumentorBase(TestBase): @@ -183,7 +190,7 @@ def test_active_tracing(self): self.assertEqual(len(spans), 1) span = spans[0] - self.assertEqual(span.name, os.environ[_HANDLER]) + self.assertEqual(span.name, MOCK_LAMBDA_CONTEXT.function_name) self.assertEqual(span.get_span_context().trace_id, MOCK_XRAY_TRACE_ID) self.assertEqual(span.kind, SpanKind.SERVER) self.assertSpanHasAttributes( @@ -419,7 +426,7 @@ def test_lambda_handles_multiple_consumers(self): assert len(spans) == 4 for span in spans: - assert span.kind == SpanKind.CONSUMER + assert span.kind == SpanKind.SERVER test_env_patch.stop() @@ -676,7 +683,7 @@ def test_dynamo_db_event_sets_attributes(self): self.assertEqual(len(spans), 1) span, *_ = spans - self.assertEqual(span.kind, SpanKind.CONSUMER) + self.assertEqual(span.kind, SpanKind.SERVER) self.assertSpanHasAttributes( span, MOCK_LAMBDA_CONTEXT_ATTRIBUTES, @@ -691,7 +698,7 @@ def test_s3_event_sets_attributes(self): self.assertEqual(len(spans), 1) span, *_ = spans - self.assertEqual(span.kind, SpanKind.CONSUMER) + self.assertEqual(span.kind, SpanKind.SERVER) self.assertSpanHasAttributes( span, MOCK_LAMBDA_CONTEXT_ATTRIBUTES, @@ -706,7 +713,7 @@ def test_sns_event_sets_attributes(self): self.assertEqual(len(spans), 1) span, *_ = spans - self.assertEqual(span.kind, SpanKind.CONSUMER) + self.assertEqual(span.kind, SpanKind.SERVER) self.assertSpanHasAttributes( span, MOCK_LAMBDA_CONTEXT_ATTRIBUTES, @@ -721,7 +728,7 @@ def test_sqs_event_sets_attributes(self): self.assertEqual(len(spans), 1) span, *_ = spans - self.assertEqual(span.kind, SpanKind.CONSUMER) + self.assertEqual(span.kind, SpanKind.SERVER) self.assertSpanHasAttributes( span, MOCK_LAMBDA_CONTEXT_ATTRIBUTES,