Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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__,
Expand All @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
Expand Down Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down