diff --git a/sentry_sdk/integrations/chalice.py b/sentry_sdk/integrations/chalice.py index 48c9e81dc4..08545b28ba 100644 --- a/sentry_sdk/integrations/chalice.py +++ b/sentry_sdk/integrations/chalice.py @@ -2,9 +2,18 @@ from functools import wraps import sentry_sdk +import sentry_sdk.traces +from sentry_sdk.consts import OP from sentry_sdk.integrations import DidNotEnable, Integration +from sentry_sdk.integrations._wsgi_common import _filter_headers from sentry_sdk.integrations.aws_lambda import _make_request_event_processor +from sentry_sdk.integrations.cloud_resource_context import ( + CLOUD_PLATFORM, + CLOUD_PROVIDER, +) +from sentry_sdk.traces import SegmentSource, SpanStatus from sentry_sdk.tracing import TransactionSource +from sentry_sdk.tracing_utils import has_span_streaming_enabled from sentry_sdk.utils import ( capture_internal_exceptions, event_from_exception, @@ -40,18 +49,42 @@ def __call__(self, event: "Any", context: "Any") -> "Any": scope.add_event_processor( _make_request_event_processor(event, context, configured_time) ) - try: - return ChaliceEventSourceHandler.__call__(self, event, context) - except Exception: - exc_info = sys.exc_info() - event, hint = event_from_exception( - exc_info, - client_options=client.options, - mechanism={"type": "chalice", "handled": False}, + + if has_span_streaming_enabled(client.options): + span = sentry_sdk.traces.start_span( + name=context.function_name, + parent_span=None, + attributes=_get_lambda_span_attributes(context), ) - sentry_sdk.capture_event(event, hint=hint) - client.flush() - reraise(*exc_info) + try: + return ChaliceEventSourceHandler.__call__(self, event, context) + except Exception: + exc_info = sys.exc_info() + span.status = SpanStatus.ERROR.value + sentry_event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={"type": "chalice", "handled": False}, + ) + sentry_sdk.capture_event(sentry_event, hint=hint) + reraise(*exc_info) + finally: + span.end() + client.flush() + + else: + try: + return ChaliceEventSourceHandler.__call__(self, event, context) + except Exception: + exc_info = sys.exc_info() + sentry_event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={"type": "chalice", "handled": False}, + ) + sentry_sdk.capture_event(sentry_event, hint=hint) + client.flush() + reraise(*exc_info) def _get_view_function_response( @@ -63,11 +96,6 @@ def wrapped_view_function(**function_args: "Any") -> "Any": with sentry_sdk.isolation_scope() as scope: with capture_internal_exceptions(): configured_time = app.lambda_context.get_remaining_time_in_millis() - scope.set_transaction_name( - app.lambda_context.function_name, - source=TransactionSource.COMPONENT, - ) - scope.add_event_processor( _make_request_event_processor( app.current_request.to_dict(), @@ -75,26 +103,74 @@ def wrapped_view_function(**function_args: "Any") -> "Any": configured_time, ) ) - try: - return view_function(**function_args) - except Exception as exc: - if isinstance(exc, ChaliceViewError): + + if has_span_streaming_enabled(client.options): + aws_context = app.lambda_context + request_dict = app.current_request.to_dict() + headers = request_dict.get("headers", {}) + + header_attrs: "Dict[str, Any]" = {} + for header, value in _filter_headers( + headers, use_annotated_value=False + ).items(): + header_attrs[f"http.request.header.{header.lower()}"] = value + + additional_attrs: "Dict[str, Any]" = {} + if "method" in request_dict: + additional_attrs["http.request.method"] = request_dict["method"] + + span = sentry_sdk.traces.start_span( + name=aws_context.function_name, + parent_span=None, + attributes={ + **_get_lambda_span_attributes(aws_context), + **header_attrs, + **additional_attrs, + }, + ) + try: + return view_function(**function_args) + except Exception as exc: + if isinstance(exc, ChaliceViewError): + raise + exc_info = sys.exc_info() + span.status = SpanStatus.ERROR.value + sentry_event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={"type": "chalice", "handled": False}, + ) + sentry_sdk.capture_event(sentry_event, hint=hint) raise - exc_info = sys.exc_info() - event, hint = event_from_exception( - exc_info, - client_options=client.options, - mechanism={"type": "chalice", "handled": False}, + finally: + span.end() + client.flush() + else: + scope.set_transaction_name( + app.lambda_context.function_name, + source=TransactionSource.COMPONENT, ) - sentry_sdk.capture_event(event, hint=hint) - client.flush() - raise + try: + return view_function(**function_args) + except Exception as exc: + if isinstance(exc, ChaliceViewError): + raise + exc_info = sys.exc_info() + sentry_event, hint = event_from_exception( + exc_info, + client_options=client.options, + mechanism={"type": "chalice", "handled": False}, + ) + sentry_sdk.capture_event(sentry_event, hint=hint) + client.flush() + raise return wrapped_view_function # type: ignore class ChaliceIntegration(Integration): identifier = "chalice" + origin = f"auto.function.{identifier}" @staticmethod def setup_once() -> None: @@ -129,3 +205,25 @@ def sentry_event_response( RestAPIEventHandler._get_view_function_response = sentry_event_response # for everything else (like events) chalice.app.EventSourceHandler = EventSourceHandler + + +def _get_lambda_span_attributes(aws_context: "Any") -> "Dict[str, Any]": + invoked_arn = aws_context.invoked_function_arn + split_invoked_arn = invoked_arn.split(":") + aws_region = split_invoked_arn[3] if len(split_invoked_arn) > 3 else "unknown" + + return { + "sentry.op": OP.FUNCTION_AWS, + "sentry.origin": ChaliceIntegration.origin, + "sentry.span.source": SegmentSource.COMPONENT, + "cloud.platform": CLOUD_PLATFORM.AWS_LAMBDA, + "cloud.provider": CLOUD_PROVIDER.AWS, + "faas.name": aws_context.function_name, + "cloud.region": aws_region, + "cloud.resource_id": invoked_arn, + "aws.lambda.invoked_arn": invoked_arn, + "faas.invocation_id": aws_context.aws_request_id, + "faas.version": aws_context.function_version, + "aws.log.group.names": [aws_context.log_group_name], + "aws.log.stream.names": [aws_context.log_stream_name], + } diff --git a/sentry_sdk/integrations/cloud_resource_context.py b/sentry_sdk/integrations/cloud_resource_context.py index 87aa07ef4c..f6285d0a9b 100644 --- a/sentry_sdk/integrations/cloud_resource_context.py +++ b/sentry_sdk/integrations/cloud_resource_context.py @@ -48,6 +48,7 @@ class CLOUD_PLATFORM: # noqa: N801 """ AWS_EC2 = "aws_ec2" + AWS_LAMBDA = "aws_lambda" GCP_COMPUTE_ENGINE = "gcp_compute_engine" diff --git a/tests/integrations/chalice/test_chalice.py b/tests/integrations/chalice/test_chalice.py index f56ad716be..522f69155e 100644 --- a/tests/integrations/chalice/test_chalice.py +++ b/tests/integrations/chalice/test_chalice.py @@ -5,11 +5,23 @@ from chalice.local import LambdaContext, LocalGateway from pytest_chalice.handlers import RequestHandler +import sentry_sdk from sentry_sdk import capture_message from sentry_sdk.integrations.chalice import CHALICE_VERSION, ChaliceIntegration from sentry_sdk.utils import parse_version +def _populate_lambda_context(context): + fn = context.function_name + context.invoked_function_arn = ( + f"arn:aws:lambda:us-east-1:123456789012:function:{fn}" + ) + context.log_group_name = f"/aws/lambda/{fn}" + context.log_stream_name = "2024/01/01/[$LATEST]abcdef1234567890" + context.aws_request_id = "test-request-id-1234" + return context + + def _generate_lambda_context(self): # Monkeypatch of the function _generate_lambda_context # from the class LocalGateway @@ -19,11 +31,12 @@ def _generate_lambda_context(self): timeout = 10 * 1000 else: timeout = self._config.lambda_timeout * 1000 - return LambdaContext( + context = LambdaContext( function_name=self._config.function_name, memory_size=self._config.lambda_memory_size, max_runtime_ms=timeout, ) + return _populate_lambda_context(context) @pytest.fixture @@ -89,8 +102,8 @@ def test_scheduled_event(app, lambda_context_args): def every_hour(event): raise Exception("schedule event!") - context = LambdaContext( - *lambda_context_args, max_runtime_ms=10000, time_source=time + context = _populate_lambda_context( + LambdaContext(*lambda_context_args, max_runtime_ms=10000, time_source=time) ) lambda_event = { @@ -160,3 +173,183 @@ def test_transaction( (event,) = events assert event["transaction"] == expected_transaction assert event["transaction_info"] == {"source": expected_source} + + +def _make_span_streaming_app(sentry_init): + sentry_init( + integrations=[ChaliceIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + app = Chalice(app_name="sentry_chalice") + + @app.route("/message") + def hi(): + capture_message("hi") + return {"status": "ok"} + + @app.route("/boom") + def boom(): + raise Exception("boom goes the dynamite!") + + LocalGateway._generate_lambda_context = _generate_lambda_context + + return app + + +def test_span_streaming_basic( + sentry_init, + capture_items, +): + app = _make_span_streaming_app(sentry_init) + client = RequestHandler(app) + items = capture_items("span") + + response = client.get("/message", headers={"X-Custom-Header": "test-value"}) + assert response.status_code == 200 + + sentry_sdk.flush() + + segment_spans = [s.payload for s in items if s.payload.get("is_segment")] + assert len(segment_spans) == 1 + span = segment_spans[0] + + attrs = span["attributes"] + assert attrs["sentry.op"] == "function.aws" + assert attrs["sentry.origin"] == "auto.function.chalice" + assert attrs["sentry.span.source"] == "component" + assert attrs["cloud.platform"] == "aws_lambda" + assert attrs["cloud.provider"] == "aws" + assert attrs["cloud.region"] == "us-east-1" + assert ( + attrs["cloud.resource_id"] + == "arn:aws:lambda:us-east-1:123456789012:function:api_handler" + ) + assert ( + attrs["aws.lambda.invoked_arn"] + == "arn:aws:lambda:us-east-1:123456789012:function:api_handler" + ) + assert attrs["faas.name"] == "api_handler" + assert attrs["faas.invocation_id"] == "test-request-id-1234" + assert attrs["faas.version"] == "$LATEST" + assert attrs["aws.log.group.names"] == ["/aws/lambda/api_handler"] + assert attrs["aws.log.stream.names"] == ["2024/01/01/[$LATEST]abcdef1234567890"] + assert attrs["http.request.method"] == "GET" + assert attrs["http.request.header.x-custom-header"] == "test-value" + assert span["status"] == "ok" + + +def test_span_streaming_error( + sentry_init, + capture_items, +): + app = _make_span_streaming_app(sentry_init) + client = RequestHandler(app) + items = capture_items("event", "span") + + response = client.get("/boom") + assert response.status_code == 500 + + sentry_sdk.flush() + + error_items = [i for i in items if i.type == "event"] + span_items = [i for i in items if i.type == "span"] + + assert len(error_items) == 1 + assert len(span_items) >= 1 + + segment_spans = [s.payload for s in span_items if s.payload.get("is_segment")] + assert len(segment_spans) == 1 + + attrs = segment_spans[0]["attributes"] + assert attrs["sentry.op"] == "function.aws" + assert attrs["sentry.origin"] == "auto.function.chalice" + assert segment_spans[0]["status"] == "error" + + +def test_span_streaming_error_flush_ordering( + sentry_init, + capture_items, +): + """The handler's own client.flush() must send the segment span. + + If flush runs before the span ends, the segment won't be in the + transport without an extra sentry_sdk.flush() call after the request. + On Lambda, the worker can freeze right after the response, so there's + no second chance. + """ + app = _make_span_streaming_app(sentry_init) + client = RequestHandler(app) + items = capture_items("span") + + response = client.get("/boom") + assert response.status_code == 500 + + segment_spans = [s.payload for s in items if s.payload.get("is_segment")] + assert len(segment_spans) == 1 + + +def test_span_streaming_scheduled_event( + sentry_init, + lambda_context_args, + capture_items, +): + sentry_init( + integrations=[ChaliceIntegration()], + traces_sample_rate=1.0, + _experiments={"trace_lifecycle": "stream"}, + ) + app = Chalice(app_name="sentry_chalice") + + @app.schedule("rate(1 minutes)") + def every_hour(event): + raise Exception("schedule event!") + + items = capture_items("event", "span") + + context = _populate_lambda_context( + LambdaContext(*lambda_context_args, max_runtime_ms=10000, time_source=time) + ) + + lambda_event = { + "version": "0", + "account": "120987654312", + "region": "us-west-1", + "detail": {}, + "detail-type": "Scheduled Event", + "source": "aws.events", + "time": "1970-01-01T00:00:00Z", + "id": "event-id", + "resources": ["arn:aws:events:us-west-1:120987654312:rule/my-schedule"], + } + with pytest.raises(Exception) as exc_info: + every_hour(lambda_event, context=context) + assert str(exc_info.value) == "schedule event!" + + sentry_sdk.flush() + + span_items = [i for i in items if i.type == "span"] + segment_spans = [s.payload for s in span_items if s.payload.get("is_segment")] + assert len(segment_spans) == 1 + + attrs = segment_spans[0]["attributes"] + assert attrs["sentry.op"] == "function.aws" + assert attrs["sentry.origin"] == "auto.function.chalice" + assert attrs["sentry.span.source"] == "component" + assert attrs["cloud.platform"] == "aws_lambda" + assert attrs["cloud.provider"] == "aws" + assert attrs["cloud.region"] == "us-east-1" + assert ( + attrs["cloud.resource_id"] + == "arn:aws:lambda:us-east-1:123456789012:function:lambda_name" + ) + assert ( + attrs["aws.lambda.invoked_arn"] + == "arn:aws:lambda:us-east-1:123456789012:function:lambda_name" + ) + assert attrs["faas.name"] == "lambda_name" + assert attrs["faas.invocation_id"] == "test-request-id-1234" + assert attrs["faas.version"] == "$LATEST" + assert attrs["aws.log.group.names"] == ["/aws/lambda/lambda_name"] + assert attrs["aws.log.stream.names"] == ["2024/01/01/[$LATEST]abcdef1234567890"] + assert segment_spans[0]["status"] == "error"