From c36f0db33af598015e2500ddc4ee66e5597c1af6 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 3 Oct 2024 17:58:00 +0200 Subject: [PATCH 01/11] Fix type of sample_rate in DSC (and add explanatory tests) (#3603) In the DSC send in the envelope header for envelopes containing errors the type of sample_rate was float instead of the correct str type. --- sentry_sdk/tracing_utils.py | 2 +- tests/test_dsc.py | 322 ++++++++++++++++++++++++++++++++++++ 2 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 tests/test_dsc.py diff --git a/sentry_sdk/tracing_utils.py b/sentry_sdk/tracing_utils.py index 461199e0cb..150e73661e 100644 --- a/sentry_sdk/tracing_utils.py +++ b/sentry_sdk/tracing_utils.py @@ -532,7 +532,7 @@ def from_options(cls, scope): sentry_items["public_key"] = Dsn(options["dsn"]).public_key if options.get("traces_sample_rate"): - sentry_items["sample_rate"] = options["traces_sample_rate"] + sentry_items["sample_rate"] = str(options["traces_sample_rate"]) return Baggage(sentry_items, third_party_items, mutable) diff --git a/tests/test_dsc.py b/tests/test_dsc.py new file mode 100644 index 0000000000..3b8cff5baf --- /dev/null +++ b/tests/test_dsc.py @@ -0,0 +1,322 @@ +""" +This tests test for the correctness of the dynamic sampling context (DSC) in the trace header of envelopes. + +The DSC is defined here: +https://develop.sentry.dev/sdk/telemetry/traces/dynamic-sampling-context/#dsc-specification + +The DSC is propagated between service using a header called "baggage". +This is not tested in this file. +""" + +import pytest + +import sentry_sdk +import sentry_sdk.client + + +def test_dsc_head_of_trace(sentry_init, capture_envelopes): + """ + Our service is the head of the trace (it starts a new trace) + and sends a transaction event to Sentry. + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + traces_sample_rate=1.0, + ) + envelopes = capture_envelopes() + + # We start a new transaction + with sentry_sdk.start_transaction(name="foo"): + pass + + assert len(envelopes) == 1 + + transaction_envelope = envelopes[0] + envelope_trace_header = transaction_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "mysecret" + + assert "sample_rate" in envelope_trace_header + assert type(envelope_trace_header["sample_rate"]) == str + assert envelope_trace_header["sample_rate"] == "1.0" + + assert "sampled" in envelope_trace_header + assert type(envelope_trace_header["sampled"]) == str + assert envelope_trace_header["sampled"] == "true" + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myapp@0.0.1" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "canary" + + assert "transaction" in envelope_trace_header + assert type(envelope_trace_header["transaction"]) == str + assert envelope_trace_header["transaction"] == "foo" + + +def test_dsc_continuation_of_trace(sentry_init, capture_envelopes): + """ + Another service calls our service and passes tracing information to us. + Our service is continuing the trace and sends a transaction event to Sentry. + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + traces_sample_rate=1.0, + ) + envelopes = capture_envelopes() + + # This is what the upstream service sends us + sentry_trace = "771a43a4192642f0b136d5159a501700-1234567890abcdef-1" + baggage = ( + "other-vendor-value-1=foo;bar;baz, " + "sentry-trace_id=771a43a4192642f0b136d5159a501700, " + "sentry-public_key=frontendpublickey, " + "sentry-sample_rate=0.01337, " + "sentry-sampled=true, " + "sentry-release=myfrontend@1.2.3, " + "sentry-environment=bird, " + "sentry-transaction=bar, " + "other-vendor-value-2=foo;bar;" + ) + incoming_http_headers = { + "HTTP_SENTRY_TRACE": sentry_trace, + "HTTP_BAGGAGE": baggage, + } + + # We continue the incoming trace and start a new transaction + transaction = sentry_sdk.continue_trace(incoming_http_headers) + with sentry_sdk.start_transaction(transaction, name="foo"): + pass + + assert len(envelopes) == 1 + + transaction_envelope = envelopes[0] + envelope_trace_header = transaction_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + assert envelope_trace_header["trace_id"] == "771a43a4192642f0b136d5159a501700" + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "frontendpublickey" + + assert "sample_rate" in envelope_trace_header + assert type(envelope_trace_header["sample_rate"]) == str + assert envelope_trace_header["sample_rate"] == "0.01337" + + assert "sampled" in envelope_trace_header + assert type(envelope_trace_header["sampled"]) == str + assert envelope_trace_header["sampled"] == "true" + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myfrontend@1.2.3" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "bird" + + assert "transaction" in envelope_trace_header + assert type(envelope_trace_header["transaction"]) == str + assert envelope_trace_header["transaction"] == "bar" + + +def test_dsc_issue(sentry_init, capture_envelopes): + """ + Our service is a standalone service that does not have tracing enabled. Just uses Sentry for error reporting. + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + ) + envelopes = capture_envelopes() + + # No transaction is started, just an error is captured + try: + 1 / 0 + except ZeroDivisionError as exp: + sentry_sdk.capture_exception(exp) + + assert len(envelopes) == 1 + + error_envelope = envelopes[0] + + envelope_trace_header = error_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "mysecret" + + assert "sample_rate" not in envelope_trace_header + + assert "sampled" not in envelope_trace_header + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myapp@0.0.1" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "canary" + + assert "transaction" not in envelope_trace_header + + +def test_dsc_issue_with_tracing(sentry_init, capture_envelopes): + """ + Our service has tracing enabled and an error occurs in an transaction. + Envelopes containing errors also have the same DSC than the transaction envelopes. + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + traces_sample_rate=1.0, + ) + envelopes = capture_envelopes() + + # We start a new transaction and an error occurs + with sentry_sdk.start_transaction(name="foo"): + try: + 1 / 0 + except ZeroDivisionError as exp: + sentry_sdk.capture_exception(exp) + + assert len(envelopes) == 2 + + error_envelope, transaction_envelope = envelopes + + assert error_envelope.headers["trace"] == transaction_envelope.headers["trace"] + + envelope_trace_header = error_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "mysecret" + + assert "sample_rate" in envelope_trace_header + assert envelope_trace_header["sample_rate"] == "1.0" + assert type(envelope_trace_header["sample_rate"]) == str + + assert "sampled" in envelope_trace_header + assert type(envelope_trace_header["sampled"]) == str + assert envelope_trace_header["sampled"] == "true" + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myapp@0.0.1" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "canary" + + assert "transaction" in envelope_trace_header + assert type(envelope_trace_header["transaction"]) == str + assert envelope_trace_header["transaction"] == "foo" + + +@pytest.mark.parametrize( + "traces_sample_rate", + [ + 0, # no traces will be started, but if incoming traces will be continued (by our instrumentations, not happening in this test) + None, # no tracing at all. This service will never create transactions. + ], +) +def test_dsc_issue_twp(sentry_init, capture_envelopes, traces_sample_rate): + """ + Our service does not have tracing enabled, but we receive tracing information from an upstream service. + Error envelopes still contain a DCS. This is called "tracing without performance" or TWP for short. + + This way if I have three services A, B, and C, and A and C have tracing enabled, but B does not, + we still can see the full trace in Sentry, and associate errors send by service B to Sentry. + (This test would be service B in this scenario) + """ + sentry_init( + dsn="https://mysecret@bla.ingest.sentry.io/12312012", + release="myapp@0.0.1", + environment="canary", + traces_sample_rate=traces_sample_rate, + ) + envelopes = capture_envelopes() + + # This is what the upstream service sends us + sentry_trace = "771a43a4192642f0b136d5159a501700-1234567890abcdef-1" + baggage = ( + "other-vendor-value-1=foo;bar;baz, " + "sentry-trace_id=771a43a4192642f0b136d5159a501700, " + "sentry-public_key=frontendpublickey, " + "sentry-sample_rate=0.01337, " + "sentry-sampled=true, " + "sentry-release=myfrontend@1.2.3, " + "sentry-environment=bird, " + "sentry-transaction=bar, " + "other-vendor-value-2=foo;bar;" + ) + incoming_http_headers = { + "HTTP_SENTRY_TRACE": sentry_trace, + "HTTP_BAGGAGE": baggage, + } + + # We continue the trace (meaning: saving the incoming trace information on the scope) + # but in this test, we do not start a transaction. + sentry_sdk.continue_trace(incoming_http_headers) + + # No transaction is started, just an error is captured + try: + 1 / 0 + except ZeroDivisionError as exp: + sentry_sdk.capture_exception(exp) + + assert len(envelopes) == 1 + + error_envelope = envelopes[0] + + envelope_trace_header = error_envelope.headers["trace"] + + assert "trace_id" in envelope_trace_header + assert type(envelope_trace_header["trace_id"]) == str + assert envelope_trace_header["trace_id"] == "771a43a4192642f0b136d5159a501700" + + assert "public_key" in envelope_trace_header + assert type(envelope_trace_header["public_key"]) == str + assert envelope_trace_header["public_key"] == "frontendpublickey" + + assert "sample_rate" in envelope_trace_header + assert type(envelope_trace_header["sample_rate"]) == str + assert envelope_trace_header["sample_rate"] == "0.01337" + + assert "sampled" in envelope_trace_header + assert type(envelope_trace_header["sampled"]) == str + assert envelope_trace_header["sampled"] == "true" + + assert "release" in envelope_trace_header + assert type(envelope_trace_header["release"]) == str + assert envelope_trace_header["release"] == "myfrontend@1.2.3" + + assert "environment" in envelope_trace_header + assert type(envelope_trace_header["environment"]) == str + assert envelope_trace_header["environment"] == "bird" + + assert "transaction" in envelope_trace_header + assert type(envelope_trace_header["transaction"]) == str + assert envelope_trace_header["transaction"] == "bar" From 508490c3161f42fa7468e0cfd0d3eacd74b91d53 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Thu, 3 Oct 2024 18:10:52 +0200 Subject: [PATCH 02/11] Consolidate contributing docs (#3606) Have only one CONTRIBUTING.md to rule them all. --------- Co-authored-by: Ivana Kellyer --- CONTRIBUTING-aws-lambda.md | 21 --------------------- CONTRIBUTING.md | 21 +++++++++++++++++++++ 2 files changed, 21 insertions(+), 21 deletions(-) delete mode 100644 CONTRIBUTING-aws-lambda.md diff --git a/CONTRIBUTING-aws-lambda.md b/CONTRIBUTING-aws-lambda.md deleted file mode 100644 index 7a6a158b45..0000000000 --- a/CONTRIBUTING-aws-lambda.md +++ /dev/null @@ -1,21 +0,0 @@ -# Contributing to Sentry AWS Lambda Layer - -All the general terms of the [CONTRIBUTING.md](CONTRIBUTING.md) apply. - -## Development environment - -You need to have a AWS account and AWS CLI installed and setup. - -We put together two helper functions that can help you with development: - -- `./scripts/aws-deploy-local-layer.sh` - - This script [scripts/aws-deploy-local-layer.sh](scripts/aws-deploy-local-layer.sh) will take the code you have checked out locally, create a Lambda layer out of it and deploy it to the `eu-central-1` region of your configured AWS account using `aws` CLI. - - The Lambda layer will have the name `SentryPythonServerlessSDK-local-dev` - -- `./scripts/aws-attach-layer-to-lambda-function.sh` - - You can use this script [scripts/aws-attach-layer-to-lambda-function.sh](scripts/aws-attach-layer-to-lambda-function.sh) to attach the Lambda layer you just deployed (using the first script) onto one of your existing Lambda functions. You will have to give the name of the Lambda function to attach onto as an argument. (See the script for details.) - -With this two helper scripts it should be easy to rapidly iterate your development on the Lambda layer. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 51765e7ef6..2f4839f8d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -172,3 +172,24 @@ sentry-sdk==2.4.0 ``` A major release `N` implies the previous release `N-1` will no longer receive updates. We generally do not backport bugfixes to older versions unless they are security relevant. However, feel free to ask for backports of specific commits on the bugtracker. + + +## Contributing to Sentry AWS Lambda Layer + +### Development environment + +You need to have an AWS account and AWS CLI installed and setup. + +We put together two helper functions that can help you with development: + +- `./scripts/aws-deploy-local-layer.sh` + + This script [scripts/aws-deploy-local-layer.sh](scripts/aws-deploy-local-layer.sh) will take the code you have checked out locally, create a Lambda layer out of it and deploy it to the `eu-central-1` region of your configured AWS account using `aws` CLI. + + The Lambda layer will have the name `SentryPythonServerlessSDK-local-dev` + +- `./scripts/aws-attach-layer-to-lambda-function.sh` + + You can use this script [scripts/aws-attach-layer-to-lambda-function.sh](scripts/aws-attach-layer-to-lambda-function.sh) to attach the Lambda layer you just deployed (using the first script) onto one of your existing Lambda functions. You will have to give the name of the Lambda function to attach onto as an argument. (See the script for details.) + +With these two helper scripts it should be easy to rapidly iterate your development on the Lambda layer. From bc87c0ddf2553c692ffabd9c17d87099011f267a Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 4 Oct 2024 09:55:38 +0200 Subject: [PATCH 03/11] Simplify tox version spec (#3609) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 2f351d7e5a..9725386f4c 100644 --- a/tox.ini +++ b/tox.ini @@ -289,7 +289,7 @@ deps = # === Common === py3.8-common: hypothesis - {py3.6,py3.7,py3.8,py3.9,py3.10,py3.11,py3.12,py3.13}-common: pytest-asyncio + common: pytest-asyncio # See https://github.com/pytest-dev/pytest/issues/9621 # and https://github.com/pytest-dev/pytest-forked/issues/67 # for justification of the upper bound on pytest From e2aa6a57e99b76301cc27bd7eaf3924373f55443 Mon Sep 17 00:00:00 2001 From: Anton Pirker Date: Fri, 4 Oct 2024 10:26:53 +0200 Subject: [PATCH 04/11] Remove useless makefile targets (#3604) --- Makefile | 35 ++--------------------------------- 1 file changed, 2 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index f0affeca11..fb5900e5ea 100644 --- a/Makefile +++ b/Makefile @@ -5,13 +5,11 @@ VENV_PATH = .venv help: @echo "Thanks for your interest in the Sentry Python SDK!" @echo - @echo "make lint: Run linters" - @echo "make test: Run basic tests (not testing most integrations)" - @echo "make test-all: Run ALL tests (slow, closest to CI)" - @echo "make format: Run code formatters (destructive)" + @echo "make apidocs: Build the API documentation" @echo "make aws-lambda-layer: Build AWS Lambda layer directory for serverless integration" @echo @echo "Also make sure to read ./CONTRIBUTING.md" + @echo @false .venv: @@ -24,30 +22,6 @@ dist: .venv $(VENV_PATH)/bin/python setup.py sdist bdist_wheel .PHONY: dist -format: .venv - $(VENV_PATH)/bin/tox -e linters --notest - .tox/linters/bin/black . -.PHONY: format - -test: .venv - @$(VENV_PATH)/bin/tox -e py3.12 -.PHONY: test - -test-all: .venv - @TOXPATH=$(VENV_PATH)/bin/tox sh ./scripts/runtox.sh -.PHONY: test-all - -check: lint test -.PHONY: check - -lint: .venv - @set -e && $(VENV_PATH)/bin/tox -e linters || ( \ - echo "================================"; \ - echo "Bad formatting? Run: make format"; \ - echo "================================"; \ - false) -.PHONY: lint - apidocs: .venv @$(VENV_PATH)/bin/pip install --editable . @$(VENV_PATH)/bin/pip install -U -r ./requirements-docs.txt @@ -55,11 +29,6 @@ apidocs: .venv @$(VENV_PATH)/bin/sphinx-build -vv -W -b html docs/ docs/_build .PHONY: apidocs -apidocs-hotfix: apidocs - @$(VENV_PATH)/bin/pip install ghp-import - @$(VENV_PATH)/bin/ghp-import -pf docs/_build -.PHONY: apidocs-hotfix - aws-lambda-layer: dist $(VENV_PATH)/bin/pip install -r requirements-aws-lambda-layer.txt $(VENV_PATH)/bin/python -m scripts.build_aws_lambda_layer From 033e3adb30b038432faee07b6bff4fa66a6de3d6 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Fri, 4 Oct 2024 10:42:45 +0200 Subject: [PATCH 05/11] ref(bottle): Delete never-reached code (#3605) The `prepared_callback` should never raise an `HTTPResponse` exception because `prepared_callback` is already decorated by Bottle using a `@route` decorator (or a decorator for the specific HTTP methods, e.g. `@get`). This decorated function never raises `HTTPResponse`, because the `@route` wrapper [captures any `HTTPResponse` exception and converts it into the return value](https://github.com/bottlepy/bottle/blob/cb36a7d83dc560e81dd131a365ee09db2f756a52/bottle.py#L2006-L2009). So, we do not need this code and should delete it. --- sentry_sdk/integrations/bottle.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/sentry_sdk/integrations/bottle.py b/sentry_sdk/integrations/bottle.py index dc573eb958..6dae8d9188 100644 --- a/sentry_sdk/integrations/bottle.py +++ b/sentry_sdk/integrations/bottle.py @@ -30,7 +30,6 @@ Bottle, Route, request as bottle_request, - HTTPResponse, __version__ as BOTTLE_VERSION, ) except ImportError: @@ -114,8 +113,6 @@ def wrapped_callback(*args, **kwargs): try: res = prepared_callback(*args, **kwargs) - except HTTPResponse: - raise except Exception as exception: event, hint = event_from_exception( exception, From 55d757a4742105cb5d0376ee909e87618cd0a09f Mon Sep 17 00:00:00 2001 From: Ivana Kellyer Date: Fri, 4 Oct 2024 10:51:47 +0200 Subject: [PATCH 06/11] Add http_methods_to_capture to ASGI Django (#3607) --- sentry_sdk/integrations/django/asgi.py | 8 ++- tests/integrations/django/asgi/test_asgi.py | 67 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/integrations/django/asgi.py b/sentry_sdk/integrations/django/asgi.py index bcc83b8e59..71b69a9bc1 100644 --- a/sentry_sdk/integrations/django/asgi.py +++ b/sentry_sdk/integrations/django/asgi.py @@ -90,13 +90,15 @@ def patch_django_asgi_handler_impl(cls): async def sentry_patched_asgi_handler(self, scope, receive, send): # type: (Any, Any, Any, Any) -> Any - if sentry_sdk.get_client().get_integration(DjangoIntegration) is None: + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if integration is None: return await old_app(self, scope, receive, send) middleware = SentryAsgiMiddleware( old_app.__get__(self, cls), unsafe_context_data=True, span_origin=DjangoIntegration.origin, + http_methods_to_capture=integration.http_methods_to_capture, )._run_asgi3 return await middleware(scope, receive, send) @@ -142,13 +144,15 @@ def patch_channels_asgi_handler_impl(cls): async def sentry_patched_asgi_handler(self, receive, send): # type: (Any, Any, Any) -> Any - if sentry_sdk.get_client().get_integration(DjangoIntegration) is None: + integration = sentry_sdk.get_client().get_integration(DjangoIntegration) + if integration is None: return await old_app(self, receive, send) middleware = SentryAsgiMiddleware( lambda _scope: old_app.__get__(self, cls), unsafe_context_data=True, span_origin=DjangoIntegration.origin, + http_methods_to_capture=integration.http_methods_to_capture, ) return await middleware(self.scope)(receive, send) diff --git a/tests/integrations/django/asgi/test_asgi.py b/tests/integrations/django/asgi/test_asgi.py index 57a6faea44..f6cfae0d2c 100644 --- a/tests/integrations/django/asgi/test_asgi.py +++ b/tests/integrations/django/asgi/test_asgi.py @@ -624,3 +624,70 @@ async def test_async_view(sentry_init, capture_events, application): (event,) = events assert event["type"] == "transaction" assert event["transaction"] == "/simple_async_view" + + +@pytest.mark.parametrize("application", APPS) +@pytest.mark.asyncio +async def test_transaction_http_method_default( + sentry_init, capture_events, application +): + """ + By default OPTIONS and HEAD requests do not create a transaction. + """ + sentry_init( + integrations=[DjangoIntegration()], + traces_sample_rate=1.0, + ) + events = capture_events() + + comm = HttpCommunicator(application, "GET", "/simple_async_view") + await comm.get_response() + await comm.wait() + + comm = HttpCommunicator(application, "OPTIONS", "/simple_async_view") + await comm.get_response() + await comm.wait() + + comm = HttpCommunicator(application, "HEAD", "/simple_async_view") + await comm.get_response() + await comm.wait() + + (event,) = events + + assert len(events) == 1 + assert event["request"]["method"] == "GET" + + +@pytest.mark.parametrize("application", APPS) +@pytest.mark.asyncio +async def test_transaction_http_method_custom(sentry_init, capture_events, application): + sentry_init( + integrations=[ + DjangoIntegration( + http_methods_to_capture=( + "OPTIONS", + "head", + ), # capitalization does not matter + ) + ], + traces_sample_rate=1.0, + ) + events = capture_events() + + comm = HttpCommunicator(application, "GET", "/simple_async_view") + await comm.get_response() + await comm.wait() + + comm = HttpCommunicator(application, "OPTIONS", "/simple_async_view") + await comm.get_response() + await comm.wait() + + comm = HttpCommunicator(application, "HEAD", "/simple_async_view") + await comm.get_response() + await comm.wait() + + assert len(events) == 2 + + (event1, event2) = events + assert event1["request"]["method"] == "OPTIONS" + assert event2["request"]["method"] == "HEAD" From 2bfce50d38e703a30a44f54a167492ddfef36229 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 4 Oct 2024 10:03:39 +0100 Subject: [PATCH 07/11] feat: Add httpcore based HTTP2Transport (#3588) All our ingest endpoints support HTTP/2 and some even HTTP/3 which are significantly more efficient compared to HTTP/1.1 with multiplexing and, header compression, connection reuse and 0-RTT TLS. This patch adds an experimental HTTP2Transport with the help of httpcore library. It makes minimal changes to the original HTTPTransport that said with httpcore we should be able to implement asyncio support easily and remove the worker logic (see #2824). This should also open the door for future HTTP/3 support (see encode/httpx#275). --------- Co-authored-by: Ivana Kellyer --- requirements-testing.txt | 2 + sentry_sdk/client.py | 4 +- sentry_sdk/consts.py | 1 + sentry_sdk/transport.py | 360 ++++++++++++++---- setup.py | 1 + .../excepthook/test_excepthook.py | 29 +- tests/test.key | 52 +++ tests/test.pem | 30 ++ tests/test_client.py | 83 +++- tests/test_transport.py | 50 ++- tests/test_utils.py | 2 +- 11 files changed, 490 insertions(+), 124 deletions(-) create mode 100644 tests/test.key create mode 100644 tests/test.pem diff --git a/requirements-testing.txt b/requirements-testing.txt index 95c015f806..0f42d6a7df 100644 --- a/requirements-testing.txt +++ b/requirements-testing.txt @@ -10,4 +10,6 @@ executing asttokens responses pysocks +socksio +httpcore[http2] setuptools diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 0dd216ab21..1598b0327c 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -23,7 +23,7 @@ ) from sentry_sdk.serializer import serialize from sentry_sdk.tracing import trace -from sentry_sdk.transport import HttpTransport, make_transport +from sentry_sdk.transport import BaseHttpTransport, make_transport from sentry_sdk.consts import ( DEFAULT_MAX_VALUE_LENGTH, DEFAULT_OPTIONS, @@ -427,7 +427,7 @@ def _capture_envelope(envelope): self.monitor or self.metrics_aggregator or has_profiling_enabled(self.options) - or isinstance(self.transport, HttpTransport) + or isinstance(self.transport, BaseHttpTransport) ): # If we have anything on that could spawn a background thread, we # need to check if it's safe to use them. diff --git a/sentry_sdk/consts.py b/sentry_sdk/consts.py index b0be144659..9a6c08d0fd 100644 --- a/sentry_sdk/consts.py +++ b/sentry_sdk/consts.py @@ -60,6 +60,7 @@ class EndpointType(Enum): "otel_powered_performance": Optional[bool], "transport_zlib_compression_level": Optional[int], "transport_num_pools": Optional[int], + "transport_http2": Optional[bool], "enable_metrics": Optional[bool], "before_emit_metric": Optional[ Callable[[str, MetricValue, MeasurementUnit, MetricTags], bool] diff --git a/sentry_sdk/transport.py b/sentry_sdk/transport.py index 6685d5c159..7a6b4f07b8 100644 --- a/sentry_sdk/transport.py +++ b/sentry_sdk/transport.py @@ -3,6 +3,7 @@ import os import gzip import socket +import ssl import time import warnings from datetime import datetime, timedelta, timezone @@ -24,13 +25,14 @@ from typing import Any from typing import Callable from typing import Dict + from typing import DefaultDict from typing import Iterable from typing import List + from typing import Mapping from typing import Optional from typing import Tuple from typing import Type from typing import Union - from typing import DefaultDict from urllib3.poolmanager import PoolManager from urllib3.poolmanager import ProxyManager @@ -193,8 +195,8 @@ def _parse_rate_limits(header, now=None): continue -class HttpTransport(Transport): - """The default HTTP transport.""" +class BaseHttpTransport(Transport): + """The base HTTP transport.""" def __init__( self, options # type: Dict[str, Any] @@ -208,19 +210,19 @@ def __init__( self._worker = BackgroundWorker(queue_size=options["transport_queue_size"]) self._auth = self.parsed_dsn.to_auth("sentry.python/%s" % VERSION) self._disabled_until = {} # type: Dict[Optional[EventDataCategory], datetime] + # We only use this Retry() class for the `get_retry_after` method it exposes self._retry = urllib3.util.Retry() self._discarded_events = defaultdict( int ) # type: DefaultDict[Tuple[EventDataCategory, str], int] self._last_client_report_sent = time.time() - compresslevel = options.get("_experiments", {}).get( + compression_level = options.get("_experiments", {}).get( "transport_zlib_compression_level" ) - self._compresslevel = 9 if compresslevel is None else int(compresslevel) - - num_pools = options.get("_experiments", {}).get("transport_num_pools") - self._num_pools = 2 if num_pools is None else int(num_pools) + self._compression_level = ( + 9 if compression_level is None else int(compression_level) + ) self._pool = self._make_pool( self.parsed_dsn, @@ -269,12 +271,16 @@ def record_lost_event( self._discarded_events[data_category, reason] += quantity + def _get_header_value(self, response, header): + # type: (Any, str) -> Optional[str] + return response.headers.get(header) + def _update_rate_limits(self, response): - # type: (urllib3.BaseHTTPResponse) -> None + # type: (Union[urllib3.BaseHTTPResponse, httpcore.Response]) -> None # new sentries with more rate limit insights. We honor this header # no matter of the status code to update our internal rate limits. - header = response.headers.get("x-sentry-rate-limits") + header = self._get_header_value(response, "x-sentry-rate-limits") if header: logger.warning("Rate-limited via x-sentry-rate-limits") self._disabled_until.update(_parse_rate_limits(header)) @@ -284,8 +290,14 @@ def _update_rate_limits(self, response): # sentries if a proxy in front wants to globally slow things down. elif response.status == 429: logger.warning("Rate-limited via 429") + retry_after_value = self._get_header_value(response, "Retry-After") + retry_after = ( + self._retry.parse_retry_after(retry_after_value) + if retry_after_value is not None + else None + ) or 60 self._disabled_until[None] = datetime.now(timezone.utc) + timedelta( - seconds=self._retry.get_retry_after(response) or 60 + seconds=retry_after ) def _send_request( @@ -312,11 +324,11 @@ def record_loss(reason): } ) try: - response = self._pool.request( + response = self._request( "POST", - str(self._auth.get_api_url(endpoint_type)), - body=body, - headers=headers, + endpoint_type, + body, + headers, ) except Exception: self.on_dropped_event("network") @@ -338,7 +350,7 @@ def record_loss(reason): logger.error( "Unexpected status code: %s (body: %s)", response.status, - response.data, + getattr(response, "data", getattr(response, "content", None)), ) self.on_dropped_event("status_{}".format(response.status)) record_loss("network_error") @@ -447,11 +459,11 @@ def _send_envelope( envelope.items.append(client_report_item) body = io.BytesIO() - if self._compresslevel == 0: + if self._compression_level == 0: envelope.serialize_into(body) else: with gzip.GzipFile( - fileobj=body, mode="w", compresslevel=self._compresslevel + fileobj=body, mode="w", compresslevel=self._compression_level ) as f: envelope.serialize_into(f) @@ -466,7 +478,7 @@ def _send_envelope( headers = { "Content-Type": "application/x-sentry-envelope", } - if self._compresslevel > 0: + if self._compression_level > 0: headers["Content-Encoding"] = "gzip" self._send_request( @@ -479,8 +491,109 @@ def _send_envelope( def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + raise NotImplementedError() + + def _in_no_proxy(self, parsed_dsn): + # type: (Dsn) -> bool + no_proxy = getproxies().get("no") + if not no_proxy: + return False + for host in no_proxy.split(","): + host = host.strip() + if parsed_dsn.host.endswith(host) or parsed_dsn.netloc.endswith(host): + return True + return False + + def _make_pool( + self, + parsed_dsn, # type: Dsn + http_proxy, # type: Optional[str] + https_proxy, # type: Optional[str] + ca_certs, # type: Optional[Any] + cert_file, # type: Optional[Any] + key_file, # type: Optional[Any] + proxy_headers, # type: Optional[Dict[str, str]] + ): + # type: (...) -> Union[PoolManager, ProxyManager, httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool] + raise NotImplementedError() + + def _request( + self, + method, + endpoint_type, + body, + headers, + ): + # type: (str, EndpointType, Any, Mapping[str, str]) -> Union[urllib3.BaseHTTPResponse, httpcore.Response] + raise NotImplementedError() + + def capture_envelope( + self, envelope # type: Envelope + ): + # type: (...) -> None + def send_envelope_wrapper(): + # type: () -> None + with capture_internal_exceptions(): + self._send_envelope(envelope) + self._flush_client_reports() + + if not self._worker.submit(send_envelope_wrapper): + self.on_dropped_event("full_queue") + for item in envelope.items: + self.record_lost_event("queue_overflow", item=item) + + def flush( + self, + timeout, # type: float + callback=None, # type: Optional[Any] + ): + # type: (...) -> None + logger.debug("Flushing HTTP transport") + + if timeout > 0: + self._worker.submit(lambda: self._flush_client_reports(force=True)) + self._worker.flush(timeout, callback) + + def kill(self): + # type: () -> None + logger.debug("Killing HTTP transport") + self._worker.kill() + + @staticmethod + def _warn_hub_cls(): + # type: () -> None + """Convenience method to warn users about the deprecation of the `hub_cls` attribute.""" + warnings.warn( + "The `hub_cls` attribute is deprecated and will be removed in a future release.", + DeprecationWarning, + stacklevel=3, + ) + + @property + def hub_cls(self): + # type: () -> type[sentry_sdk.Hub] + """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" + HttpTransport._warn_hub_cls() + return self._hub_cls + + @hub_cls.setter + def hub_cls(self, value): + # type: (type[sentry_sdk.Hub]) -> None + """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" + HttpTransport._warn_hub_cls() + self._hub_cls = value + + +class HttpTransport(BaseHttpTransport): + if TYPE_CHECKING: + _pool: Union[PoolManager, ProxyManager] + + def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): + # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + + num_pools = self.options.get("_experiments", {}).get("transport_num_pools") options = { - "num_pools": self._num_pools, + "num_pools": 2 if num_pools is None else int(num_pools), "cert_reqs": "CERT_REQUIRED", } @@ -513,17 +626,6 @@ def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): return options - def _in_no_proxy(self, parsed_dsn): - # type: (Dsn) -> bool - no_proxy = getproxies().get("no") - if not no_proxy: - return False - for host in no_proxy.split(","): - host = host.strip() - if parsed_dsn.host.endswith(host) or parsed_dsn.netloc.endswith(host): - return True - return False - def _make_pool( self, parsed_dsn, # type: Dsn @@ -555,7 +657,7 @@ def _make_pool( if proxy.startswith("socks"): use_socks_proxy = True try: - # Check if PySocks depencency is available + # Check if PySocks dependency is available from urllib3.contrib.socks import SOCKSProxyManager except ImportError: use_socks_proxy = False @@ -573,61 +675,155 @@ def _make_pool( else: return urllib3.PoolManager(**opts) - def capture_envelope( - self, envelope # type: Envelope + def _request( + self, + method, + endpoint_type, + body, + headers, ): - # type: (...) -> None - def send_envelope_wrapper(): - # type: () -> None - with capture_internal_exceptions(): - self._send_envelope(envelope) - self._flush_client_reports() + # type: (str, EndpointType, Any, Mapping[str, str]) -> urllib3.BaseHTTPResponse + return self._pool.request( + method, + self._auth.get_api_url(endpoint_type), + body=body, + headers=headers, + ) - if not self._worker.submit(send_envelope_wrapper): - self.on_dropped_event("full_queue") - for item in envelope.items: - self.record_lost_event("queue_overflow", item=item) - def flush( - self, - timeout, # type: float - callback=None, # type: Optional[Any] - ): - # type: (...) -> None - logger.debug("Flushing HTTP transport") +try: + import httpcore +except ImportError: + # Sorry, no Http2Transport for you + class Http2Transport(HttpTransport): + def __init__( + self, options # type: Dict[str, Any] + ): + # type: (...) -> None + super().__init__(options) + logger.warning( + "You tried to use HTTP2Transport but don't have httpcore[http2] installed. Falling back to HTTPTransport." + ) - if timeout > 0: - self._worker.submit(lambda: self._flush_client_reports(force=True)) - self._worker.flush(timeout, callback) +else: + + class Http2Transport(BaseHttpTransport): # type: ignore + """The HTTP2 transport based on httpcore.""" + + if TYPE_CHECKING: + _pool: Union[ + httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool + ] + + def _get_header_value(self, response, header): + # type: (httpcore.Response, str) -> Optional[str] + return next( + ( + val.decode("ascii") + for key, val in response.headers + if key.decode("ascii").lower() == header + ), + None, + ) - def kill(self): - # type: () -> None - logger.debug("Killing HTTP transport") - self._worker.kill() + def _request( + self, + method, + endpoint_type, + body, + headers, + ): + # type: (str, EndpointType, Any, Mapping[str, str]) -> httpcore.Response + response = self._pool.request( + method, + self._auth.get_api_url(endpoint_type), + content=body, + headers=headers, # type: ignore + ) + return response + + def _get_pool_options(self, ca_certs, cert_file=None, key_file=None): + # type: (Optional[Any], Optional[Any], Optional[Any]) -> Dict[str, Any] + options = { + "http2": True, + "retries": 3, + } # type: Dict[str, Any] + + socket_options = ( + self.options["socket_options"] + if self.options["socket_options"] is not None + else [] + ) - @staticmethod - def _warn_hub_cls(): - # type: () -> None - """Convenience method to warn users about the deprecation of the `hub_cls` attribute.""" - warnings.warn( - "The `hub_cls` attribute is deprecated and will be removed in a future release.", - DeprecationWarning, - stacklevel=3, - ) + used_options = {(o[0], o[1]) for o in socket_options} + for default_option in KEEP_ALIVE_SOCKET_OPTIONS: + if (default_option[0], default_option[1]) not in used_options: + socket_options.append(default_option) - @property - def hub_cls(self): - # type: () -> type[sentry_sdk.Hub] - """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" - HttpTransport._warn_hub_cls() - return self._hub_cls + options["socket_options"] = socket_options - @hub_cls.setter - def hub_cls(self, value): - # type: (type[sentry_sdk.Hub]) -> None - """DEPRECATED: This attribute is deprecated and will be removed in a future release.""" - HttpTransport._warn_hub_cls() - self._hub_cls = value + ssl_context = ssl.create_default_context() + ssl_context.load_verify_locations( + ca_certs # User-provided bundle from the SDK init + or os.environ.get("SSL_CERT_FILE") + or os.environ.get("REQUESTS_CA_BUNDLE") + or certifi.where() + ) + cert_file = cert_file or os.environ.get("CLIENT_CERT_FILE") + key_file = key_file or os.environ.get("CLIENT_KEY_FILE") + if cert_file is not None: + ssl_context.load_cert_chain(cert_file, key_file) + + options["ssl_context"] = ssl_context + + return options + + def _make_pool( + self, + parsed_dsn, # type: Dsn + http_proxy, # type: Optional[str] + https_proxy, # type: Optional[str] + ca_certs, # type: Optional[Any] + cert_file, # type: Optional[Any] + key_file, # type: Optional[Any] + proxy_headers, # type: Optional[Dict[str, str]] + ): + # type: (...) -> Union[httpcore.SOCKSProxy, httpcore.HTTPProxy, httpcore.ConnectionPool] + proxy = None + no_proxy = self._in_no_proxy(parsed_dsn) + + # try HTTPS first + if parsed_dsn.scheme == "https" and (https_proxy != ""): + proxy = https_proxy or (not no_proxy and getproxies().get("https")) + + # maybe fallback to HTTP proxy + if not proxy and (http_proxy != ""): + proxy = http_proxy or (not no_proxy and getproxies().get("http")) + + opts = self._get_pool_options(ca_certs, cert_file, key_file) + + if proxy: + if proxy_headers: + opts["proxy_headers"] = proxy_headers + + if proxy.startswith("socks"): + try: + if "socket_options" in opts: + socket_options = opts.pop("socket_options") + if socket_options: + logger.warning( + "You have defined socket_options but using a SOCKS proxy which doesn't support these. We'll ignore socket_options." + ) + return httpcore.SOCKSProxy(proxy_url=proxy, **opts) + except RuntimeError: + logger.warning( + "You have configured a SOCKS proxy (%s) but support for SOCKS proxies is not installed. Disabling proxy support.", + proxy, + ) + else: + return httpcore.HTTPProxy(proxy_url=proxy, **opts) + + return httpcore.ConnectionPool(**opts) class _FunctionTransport(Transport): @@ -663,8 +859,12 @@ def make_transport(options): # type: (Dict[str, Any]) -> Optional[Transport] ref_transport = options["transport"] + use_http2_transport = options.get("_experiments", {}).get("transport_http2", False) + # By default, we use the http transport class - transport_cls = HttpTransport # type: Type[Transport] + transport_cls = ( + Http2Transport if use_http2_transport else HttpTransport + ) # type: Type[Transport] if isinstance(ref_transport, Transport): return ref_transport diff --git a/setup.py b/setup.py index b5be538292..0432533247 100644 --- a/setup.py +++ b/setup.py @@ -58,6 +58,7 @@ def get_file_text(file_name): "fastapi": ["fastapi>=0.79.0"], "flask": ["flask>=0.11", "blinker>=1.1", "markupsafe"], "grpcio": ["grpcio>=1.21.1", "protobuf>=3.8.0"], + "http2": ["httpcore[http2]==1.*"], "httpx": ["httpx>=0.16.0"], "huey": ["huey>=2"], "huggingface_hub": ["huggingface_hub>=0.22"], diff --git a/tests/integrations/excepthook/test_excepthook.py b/tests/integrations/excepthook/test_excepthook.py index 7cb4e8b765..82fe6c6861 100644 --- a/tests/integrations/excepthook/test_excepthook.py +++ b/tests/integrations/excepthook/test_excepthook.py @@ -5,7 +5,14 @@ from textwrap import dedent -def test_excepthook(tmpdir): +TEST_PARAMETERS = [("", "HttpTransport")] + +if sys.version_info >= (3, 8): + TEST_PARAMETERS.append(('_experiments={"transport_http2": True}', "Http2Transport")) + + +@pytest.mark.parametrize("options, transport", TEST_PARAMETERS) +def test_excepthook(tmpdir, options, transport): app = tmpdir.join("app.py") app.write( dedent( @@ -18,14 +25,16 @@ def capture_envelope(self, envelope): if event is not None: print(event) - transport.HttpTransport.capture_envelope = capture_envelope + transport.{transport}.capture_envelope = capture_envelope - init("http://foobar@localhost/123") + init("http://foobar@localhost/123", {options}) frame_value = "LOL" 1/0 - """ + """.format( + transport=transport, options=options + ) ) ) @@ -40,7 +49,8 @@ def capture_envelope(self, envelope): assert b"capture_envelope was called" in output -def test_always_value_excepthook(tmpdir): +@pytest.mark.parametrize("options, transport", TEST_PARAMETERS) +def test_always_value_excepthook(tmpdir, options, transport): app = tmpdir.join("app.py") app.write( dedent( @@ -55,17 +65,20 @@ def capture_envelope(self, envelope): if event is not None: print(event) - transport.HttpTransport.capture_envelope = capture_envelope + transport.{transport}.capture_envelope = capture_envelope sys.ps1 = "always_value_test" init("http://foobar@localhost/123", - integrations=[ExcepthookIntegration(always_run=True)] + integrations=[ExcepthookIntegration(always_run=True)], + {options} ) frame_value = "LOL" 1/0 - """ + """.format( + transport=transport, options=options + ) ) ) diff --git a/tests/test.key b/tests/test.key new file mode 100644 index 0000000000..bf066c169d --- /dev/null +++ b/tests/test.key @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +zQTVuucYfxc1ph7H0ppUOZQNZ1Fo7w== +-----END PRIVATE KEY----- diff --git a/tests/test.pem b/tests/test.pem new file mode 100644 index 0000000000..2473a09452 --- /dev/null +++ b/tests/test.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFETCCAvkCFEtmfMHeEvO+RUV9Qx0bkr7VWpdSMA0GCSqGSIb3DQEBCwUAMEUx +CzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl +cm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMjQwOTE3MjEwNDE1WhcNMjUwOTE3MjEw +NDE1WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UE +CgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOC +Ag8AMIICCgKCAgEAjUoAkzuT3O6NtQX73w7/1A8A8nRISJ6LDRu5YLpqnpU+HciX +GFqIg2AGfM3+ppTxWsN6GKLqvp0KP2eXkTUA+gMYV8POqgDY3H7NR1qD/He44Efm +Xpa7IWnbwQRmFCYYg5I1Fm/I1O2/TNvM+ZuEgOXp30q0GGX1Za1/DBcVe9MLOXbi +8nvdhWI6ahQVnnhrZrMNYllDtNtDY2254KXEZkYgauNSrj8nQ/CFlkLXqDlQCN1N +o8BCNn6AJF0xwfpEBf/zbVFs+comWOLoI2NtKdLfALKJtaAyvaS9kMuBEKbAnr/+ +kifqO7CuqEdYMq86HZ+oXKiW6nF76WOsYFyDtNh/MJ+3tzZACWZHxViQMiLJWK7q +zHJ2cA2i6cy9+b8lC7HjrQ37FkN8qk35Vx3ANmZbo7r+65qukPhgmiycW5o+Wz1L +7/AwAEXfewV3+e7OgvbK0/JJORJG31EVkYjvI+Vwyz8qrGj9XP6TD3ntQhnbWOoH +/RA1oJor4AGLx9u6asyodCSQLJifKdWaecRTLRJM6E5Si9o6nskuswTv6PvTByZX +eTh3FL5Vpu+u7yinvvmldiAN4fZwUL5mwkPYtLnxl2WGbSVGJOk/BDC3Jb7UM+Mi +0QGMdDyxTin4taQHwwBwRRH8FprgpHGKsPHq2236I9KqZ7gxI+OYlLR+YEcCAwEA +ATANBgkqhkiG9w0BAQsFAAOCAgEAgFVmFmk7duJRYqktcc4/qpbGUQTaalcjBvMQ +SnTS0l3WNTwOeUBbCR6V72LOBhRG1hqsQJIlXFIuoFY7WbQoeHciN58abwXan3N+ +4Kzuue5oFdj2AK9UTSKE09cKHoBD5uwiuU1oMGRxvq0+nUaJMoC333TNBXlIFV6K +SZFfD+MpzoNdn02PtjSBzsu09szzC+r8ZyKUwtG6xTLRBA8vrukWgBYgn9CkniJk +gLw8z5FioOt8ISEkAqvtyfJPi0FkUBb/vFXwXaaM8Vvn++ssYiUes0K5IzF+fQ5l +Bv8PIkVXFrNKuvzUgpO9IaUuQavSHFC0w0FEmbWsku7UxgPvLFPqmirwcnrkQjVR +eyE25X2Sk6AucnfIFGUvYPcLGJ71Z8mjH0baB2a/zo8vnWR1rqiUfptNomm42WMm +PaprIC0684E0feT+cqbN+LhBT9GqXpaG3emuguxSGMkff4RtPv/3DOFNk9KAIK8i +7GWCBjW5GF7mkTdQtYqVi1d87jeuGZ1InF1FlIZaswWGeG6Emml+Gxa50Z7Kpmc7 +f2vZlg9E8kmbRttCVUx4kx5PxKOI6s/ebKTFbHO+ZXJtm8MyOTrAJLfnFo4SUA90 +zX6CzyP1qu1/qdf9+kT0o0JeEsqg+0f4yhp3x/xH5OsAlUpRHvRr2aB3ZYi/4Vwj +53fMNXk= +-----END CERTIFICATE----- diff --git a/tests/test_client.py b/tests/test_client.py index 60799abc58..450e19603f 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -246,7 +246,10 @@ def test_transport_option(monkeypatch): }, ], ) -def test_proxy(monkeypatch, testcase): +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) +def test_proxy(monkeypatch, testcase, http2): if testcase["env_http_proxy"] is not None: monkeypatch.setenv("HTTP_PROXY", testcase["env_http_proxy"]) if testcase["env_https_proxy"] is not None: @@ -256,6 +259,9 @@ def test_proxy(monkeypatch, testcase): kwargs = {} + if http2: + kwargs["_experiments"] = {"transport_http2": True} + if testcase["arg_http_proxy"] is not None: kwargs["http_proxy"] = testcase["arg_http_proxy"] if testcase["arg_https_proxy"] is not None: @@ -265,13 +271,31 @@ def test_proxy(monkeypatch, testcase): client = Client(testcase["dsn"], **kwargs) + proxy = getattr( + client.transport._pool, + "proxy", + getattr(client.transport._pool, "_proxy_url", None), + ) if testcase["expected_proxy_scheme"] is None: - assert client.transport._pool.proxy is None + assert proxy is None else: - assert client.transport._pool.proxy.scheme == testcase["expected_proxy_scheme"] + scheme = ( + proxy.scheme.decode("ascii") + if isinstance(proxy.scheme, bytes) + else proxy.scheme + ) + assert scheme == testcase["expected_proxy_scheme"] if testcase.get("arg_proxy_headers") is not None: - assert client.transport._pool.proxy_headers == testcase["arg_proxy_headers"] + proxy_headers = ( + dict( + (k.decode("ascii"), v.decode("ascii")) + for k, v in client.transport._pool._proxy_headers + ) + if http2 + else client.transport._pool.proxy_headers + ) + assert proxy_headers == testcase["arg_proxy_headers"] @pytest.mark.parametrize( @@ -281,68 +305,79 @@ def test_proxy(monkeypatch, testcase): "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "http://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": False, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "socks4a://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "socks4://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "socks5h://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": "socks5://localhost/123", "arg_https_proxy": None, - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": None, "arg_https_proxy": "socks4a://localhost/123", - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": None, "arg_https_proxy": "socks4://localhost/123", - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": None, "arg_https_proxy": "socks5h://localhost/123", - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, { "dsn": "https://foo@sentry.io/123", "arg_http_proxy": None, "arg_https_proxy": "socks5://localhost/123", - "expected_proxy_class": "", + "should_be_socks_proxy": True, }, ], ) -def test_socks_proxy(testcase): +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) +def test_socks_proxy(testcase, http2): kwargs = {} + if http2: + kwargs["_experiments"] = {"transport_http2": True} + if testcase["arg_http_proxy"] is not None: kwargs["http_proxy"] = testcase["arg_http_proxy"] if testcase["arg_https_proxy"] is not None: kwargs["https_proxy"] = testcase["arg_https_proxy"] client = Client(testcase["dsn"], **kwargs) - assert str(type(client.transport._pool)) == testcase["expected_proxy_class"] + assert ("socks" in str(type(client.transport._pool)).lower()) == testcase[ + "should_be_socks_proxy" + ], ( + f"Expected {kwargs} to result in SOCKS == {testcase['should_be_socks_proxy']}" + f"but got {str(type(client.transport._pool))}" + ) def test_simple_transport(sentry_init): @@ -533,7 +568,17 @@ def test_capture_event_works(sentry_init): @pytest.mark.parametrize("num_messages", [10, 20]) -def test_atexit(tmpdir, monkeypatch, num_messages): +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) +def test_atexit(tmpdir, monkeypatch, num_messages, http2): + if http2: + options = '_experiments={"transport_http2": True}' + transport = "Http2Transport" + else: + options = "" + transport = "HttpTransport" + app = tmpdir.join("app.py") app.write( dedent( @@ -547,13 +592,13 @@ def capture_envelope(self, envelope): message = event.get("message", "") print(message) - transport.HttpTransport.capture_envelope = capture_envelope - init("http://foobar@localhost/123", shutdown_timeout={num_messages}) + transport.{transport}.capture_envelope = capture_envelope + init("http://foobar@localhost/123", shutdown_timeout={num_messages}, {options}) for _ in range({num_messages}): capture_message("HI") """.format( - num_messages=num_messages + transport=transport, options=options, num_messages=num_messages ) ) ) diff --git a/tests/test_transport.py b/tests/test_transport.py index 2e2ad3c4cd..8c69a47c54 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -2,7 +2,9 @@ import pickle import gzip import io +import os import socket +import sys from collections import defaultdict, namedtuple from datetime import datetime, timedelta, timezone from unittest import mock @@ -91,7 +93,7 @@ def make_client(request, capturing_server): def inner(**kwargs): return Client( "http://foobar@{}/132".format(capturing_server.url[len("http://") :]), - **kwargs + **kwargs, ) return inner @@ -115,7 +117,10 @@ def mock_transaction_envelope(span_count): @pytest.mark.parametrize("debug", (True, False)) @pytest.mark.parametrize("client_flush_method", ["close", "flush"]) @pytest.mark.parametrize("use_pickle", (True, False)) -@pytest.mark.parametrize("compressionlevel", (0, 9)) +@pytest.mark.parametrize("compression_level", (0, 9)) +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) def test_transport_works( capturing_server, request, @@ -125,15 +130,22 @@ def test_transport_works( make_client, client_flush_method, use_pickle, - compressionlevel, + compression_level, + http2, maybe_monkeypatched_threading, ): caplog.set_level(logging.DEBUG) + + experiments = { + "transport_zlib_compression_level": compression_level, + } + + if http2: + experiments["transport_http2"] = True + client = make_client( debug=debug, - _experiments={ - "transport_zlib_compression_level": compressionlevel, - }, + _experiments=experiments, ) if use_pickle: @@ -152,7 +164,7 @@ def test_transport_works( out, err = capsys.readouterr() assert not err and not out assert capturing_server.captured - assert capturing_server.captured[0].compressed == (compressionlevel > 0) + assert capturing_server.captured[0].compressed == (compression_level > 0) assert any("Sending envelope" in record.msg for record in caplog.records) == debug @@ -176,16 +188,26 @@ def test_transport_num_pools(make_client, num_pools, expected_num_pools): assert options["num_pools"] == expected_num_pools -def test_two_way_ssl_authentication(make_client): +@pytest.mark.parametrize( + "http2", [True, False] if sys.version_info >= (3, 8) else [False] +) +def test_two_way_ssl_authentication(make_client, http2): _experiments = {} + if http2: + _experiments["transport_http2"] = True client = make_client(_experiments=_experiments) - options = client.transport._get_pool_options( - [], "/path/to/cert.pem", "/path/to/key.pem" - ) - assert options["cert_file"] == "/path/to/cert.pem" - assert options["key_file"] == "/path/to/key.pem" + current_dir = os.path.dirname(__file__) + cert_file = f"{current_dir}/test.pem" + key_file = f"{current_dir}/test.key" + options = client.transport._get_pool_options([], cert_file, key_file) + + if http2: + assert options["ssl_context"] is not None + else: + assert options["cert_file"] == cert_file + assert options["key_file"] == key_file def test_socket_options(make_client): @@ -208,7 +230,7 @@ def test_keep_alive_true(make_client): assert options["socket_options"] == KEEP_ALIVE_SOCKET_OPTIONS -def test_keep_alive_off_by_default(make_client): +def test_keep_alive_on_by_default(make_client): client = make_client() options = client.transport._get_pool_options([]) assert "socket_options" not in options diff --git a/tests/test_utils.py b/tests/test_utils.py index c46cac7f9f..eaf382c773 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -71,7 +71,7 @@ def _normalize_distribution_name(name): ), # UTC time ( "2021-01-01T00:00:00.000000", - datetime(2021, 1, 1, tzinfo=datetime.now().astimezone().tzinfo), + datetime(2021, 1, 1, tzinfo=timezone.utc), ), # No TZ -- assume UTC ( "2021-01-01T00:00:00Z", From 00f8140d55dcd981e68a160a2c1deb824b51ffc3 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Fri, 4 Oct 2024 14:20:34 +0100 Subject: [PATCH 08/11] feat(django): Add SpotlightMiddleware when Spotlight is enabled (#3600) This patch replaces Django's debug error page with Spotlight when it is enabled and is running. It bails when DEBUG is False, when it cannot connect to the Spotlight web server, or when explicitly turned off with SENTRY_SPOTLIGHT_ON_ERROR=0. --- sentry_sdk/client.py | 4 +- sentry_sdk/spotlight.py | 53 ++++++++++++++++++++++++- tests/integrations/django/test_basic.py | 51 ++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 1598b0327c..9d30bb45f2 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -61,6 +61,7 @@ from sentry_sdk.metrics import MetricsAggregator from sentry_sdk.scope import Scope from sentry_sdk.session import Session + from sentry_sdk.spotlight import SpotlightClient from sentry_sdk.transport import Transport I = TypeVar("I", bound=Integration) # noqa: E741 @@ -153,6 +154,8 @@ class BaseClient: The basic definition of a client that is used for sending data to Sentry. """ + spotlight = None # type: Optional[SpotlightClient] + def __init__(self, options=None): # type: (Optional[Dict[str, Any]]) -> None self.options = ( @@ -385,7 +388,6 @@ def _capture_envelope(envelope): disabled_integrations=self.options["disabled_integrations"], ) - self.spotlight = None spotlight_config = self.options.get("spotlight") if spotlight_config is None and "SENTRY_SPOTLIGHT" in os.environ: spotlight_env_value = os.environ["SENTRY_SPOTLIGHT"] diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index 3a5a713077..3e8072b5d8 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -1,14 +1,19 @@ import io +import os +import urllib.parse +import urllib.request +import urllib.error import urllib3 from typing import TYPE_CHECKING if TYPE_CHECKING: from typing import Any + from typing import Callable from typing import Dict from typing import Optional -from sentry_sdk.utils import logger +from sentry_sdk.utils import logger, env_to_bool from sentry_sdk.envelope import Envelope @@ -46,6 +51,47 @@ def capture_envelope(self, envelope): logger.warning(str(e)) +try: + from django.http import HttpResponseServerError + from django.conf import settings + + class SpotlightMiddleware: + def __init__(self, get_response): + # type: (Any, Callable[..., Any]) -> None + self.get_response = get_response + + def __call__(self, request): + # type: (Any, Any) -> Any + return self.get_response(request) + + def process_exception(self, _request, exception): + # type: (Any, Any, Exception) -> Optional[HttpResponseServerError] + if not settings.DEBUG: + return None + + import sentry_sdk.api + + spotlight_client = sentry_sdk.api.get_client().spotlight + if spotlight_client is None: + return None + + # Spotlight URL has a trailing `/stream` part at the end so split it off + spotlight_url = spotlight_client.url.rsplit("/", 1)[0] + + try: + spotlight = ( + urllib.request.urlopen(spotlight_url).read().decode("utf-8") + ).replace("", f'') + except urllib.error.URLError: + return None + else: + sentry_sdk.api.capture_exception(exception) + return HttpResponseServerError(spotlight) + +except ImportError: + settings = None + + def setup_spotlight(options): # type: (Dict[str, Any]) -> Optional[SpotlightClient] @@ -58,4 +104,9 @@ def setup_spotlight(options): else: return None + if settings is not None and env_to_bool( + os.environ.get("SENTRY_SPOTLIGHT_ON_ERROR", "1") + ): + settings.MIDDLEWARE.append("sentry_sdk.spotlight.SpotlightMiddleware") + return SpotlightClient(url) diff --git a/tests/integrations/django/test_basic.py b/tests/integrations/django/test_basic.py index 2089f1e936..a8cc02fda5 100644 --- a/tests/integrations/django/test_basic.py +++ b/tests/integrations/django/test_basic.py @@ -1240,3 +1240,54 @@ def test_transaction_http_method_custom(sentry_init, client, capture_events): (event1, event2) = events assert event1["request"]["method"] == "OPTIONS" assert event2["request"]["method"] == "HEAD" + + +def test_ensures_spotlight_middleware_when_spotlight_is_enabled(sentry_init, settings): + """ + Test that ensures if Spotlight is enabled, relevant SpotlightMiddleware + is added to middleware list in settings. + """ + original_middleware = frozenset(settings.MIDDLEWARE) + + sentry_init(integrations=[DjangoIntegration()], spotlight=True) + + added = frozenset(settings.MIDDLEWARE) ^ original_middleware + + assert "sentry_sdk.spotlight.SpotlightMiddleware" in added + + +def test_ensures_no_spotlight_middleware_when_env_killswitch_is_false( + monkeypatch, sentry_init, settings +): + """ + Test that ensures if Spotlight is enabled, but is set to a falsy value + the relevant SpotlightMiddleware is NOT added to middleware list in settings. + """ + monkeypatch.setenv("SENTRY_SPOTLIGHT_ON_ERROR", "no") + + original_middleware = frozenset(settings.MIDDLEWARE) + + sentry_init(integrations=[DjangoIntegration()], spotlight=True) + + added = frozenset(settings.MIDDLEWARE) ^ original_middleware + + assert "sentry_sdk.spotlight.SpotlightMiddleware" not in added + + +def test_ensures_no_spotlight_middleware_when_no_spotlight( + monkeypatch, sentry_init, settings +): + """ + Test that ensures if Spotlight is not enabled + the relevant SpotlightMiddleware is NOT added to middleware list in settings. + """ + # We should NOT have the middleware even if the env var is truthy if Spotlight is off + monkeypatch.setenv("SENTRY_SPOTLIGHT_ON_ERROR", "1") + + original_middleware = frozenset(settings.MIDDLEWARE) + + sentry_init(integrations=[DjangoIntegration()], spotlight=False) + + added = frozenset(settings.MIDDLEWARE) ^ original_middleware + + assert "sentry_sdk.spotlight.SpotlightMiddleware" not in added From be64348d60a3843c0c1bfc1446558642637ff66b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:03:08 +0000 Subject: [PATCH 09/11] build(deps): bump codecov/codecov-action from 4.5.0 to 4.6.0 (#3617) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 4.6.0. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.5.0...v4.6.0) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Daniel Szoke --- .github/workflows/test-integrations-ai.yml | 4 ++-- .github/workflows/test-integrations-aws-lambda.yml | 2 +- .github/workflows/test-integrations-cloud-computing.yml | 4 ++-- .github/workflows/test-integrations-common.yml | 2 +- .github/workflows/test-integrations-data-processing.yml | 4 ++-- .github/workflows/test-integrations-databases.yml | 4 ++-- .github/workflows/test-integrations-graphql.yml | 4 ++-- .github/workflows/test-integrations-miscellaneous.yml | 4 ++-- .github/workflows/test-integrations-networking.yml | 4 ++-- .github/workflows/test-integrations-web-frameworks-1.yml | 4 ++-- .github/workflows/test-integrations-web-frameworks-2.yml | 4 ++-- scripts/split-tox-gh-actions/templates/test_group.jinja | 2 +- 12 files changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test-integrations-ai.yml b/.github/workflows/test-integrations-ai.yml index 1a9f9a6e1b..03ef169ec9 100644 --- a/.github/workflows/test-integrations-ai.yml +++ b/.github/workflows/test-integrations-ai.yml @@ -78,7 +78,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -150,7 +150,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-aws-lambda.yml b/.github/workflows/test-integrations-aws-lambda.yml index d1996d288d..b1127421b2 100644 --- a/.github/workflows/test-integrations-aws-lambda.yml +++ b/.github/workflows/test-integrations-aws-lambda.yml @@ -97,7 +97,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-cloud-computing.yml b/.github/workflows/test-integrations-cloud-computing.yml index ecaf412274..e717bc1695 100644 --- a/.github/workflows/test-integrations-cloud-computing.yml +++ b/.github/workflows/test-integrations-cloud-computing.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-common.yml b/.github/workflows/test-integrations-common.yml index 03673b8061..d278ba9469 100644 --- a/.github/workflows/test-integrations-common.yml +++ b/.github/workflows/test-integrations-common.yml @@ -62,7 +62,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-data-processing.yml b/.github/workflows/test-integrations-data-processing.yml index f2029df24f..91b00d3337 100644 --- a/.github/workflows/test-integrations-data-processing.yml +++ b/.github/workflows/test-integrations-data-processing.yml @@ -92,7 +92,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -178,7 +178,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-databases.yml b/.github/workflows/test-integrations-databases.yml index 6a9f43eac0..4c96cb57ea 100644 --- a/.github/workflows/test-integrations-databases.yml +++ b/.github/workflows/test-integrations-databases.yml @@ -101,7 +101,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -196,7 +196,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-graphql.yml b/.github/workflows/test-integrations-graphql.yml index 3f35caa706..e613432402 100644 --- a/.github/workflows/test-integrations-graphql.yml +++ b/.github/workflows/test-integrations-graphql.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-miscellaneous.yml b/.github/workflows/test-integrations-miscellaneous.yml index 5761fa4434..f64c046cfd 100644 --- a/.github/workflows/test-integrations-miscellaneous.yml +++ b/.github/workflows/test-integrations-miscellaneous.yml @@ -78,7 +78,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -150,7 +150,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-networking.yml b/.github/workflows/test-integrations-networking.yml index 5469cf89a1..6037ec74c4 100644 --- a/.github/workflows/test-integrations-networking.yml +++ b/.github/workflows/test-integrations-networking.yml @@ -74,7 +74,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -142,7 +142,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-frameworks-1.yml b/.github/workflows/test-integrations-web-frameworks-1.yml index 0a1e2935fb..e3d065fdde 100644 --- a/.github/workflows/test-integrations-web-frameworks-1.yml +++ b/.github/workflows/test-integrations-web-frameworks-1.yml @@ -92,7 +92,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -178,7 +178,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/.github/workflows/test-integrations-web-frameworks-2.yml b/.github/workflows/test-integrations-web-frameworks-2.yml index c6e2268a43..a03f7dc2dc 100644 --- a/.github/workflows/test-integrations-web-frameworks-2.yml +++ b/.github/workflows/test-integrations-web-frameworks-2.yml @@ -98,7 +98,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml @@ -190,7 +190,7 @@ jobs: coverage xml - name: Upload coverage to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} files: coverage.xml diff --git a/scripts/split-tox-gh-actions/templates/test_group.jinja b/scripts/split-tox-gh-actions/templates/test_group.jinja index f232fb0bc4..ce3350ae39 100644 --- a/scripts/split-tox-gh-actions/templates/test_group.jinja +++ b/scripts/split-tox-gh-actions/templates/test_group.jinja @@ -92,7 +92,7 @@ - name: Upload coverage to Codecov if: {% raw %}${{ !cancelled() }}{% endraw %} - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: {% raw %}${{ secrets.CODECOV_TOKEN }}{% endraw %} files: coverage.xml From a31c54f86eea23a5dfe8da3ee7dbe366fc7d813d Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Mon, 7 Oct 2024 13:53:48 +0100 Subject: [PATCH 10/11] fix: Open relevant error when SpotlightMiddleware is on (#3614) This fixes an issue with the recent SpotlightMiddleware patch where the error triggered the page was not highlighted/opened automatically. It changes the semapics of `capture_event` and methods depending on this a bit: we now return the event_id if the error is sent to spotlight even if it was not sent upstream to Sentry servers. --- sentry_sdk/client.py | 19 +++++++++---------- sentry_sdk/spotlight.py | 18 +++++++++++++----- 2 files changed, 22 insertions(+), 15 deletions(-) diff --git a/sentry_sdk/client.py b/sentry_sdk/client.py index 9d30bb45f2..b1e7868031 100644 --- a/sentry_sdk/client.py +++ b/sentry_sdk/client.py @@ -753,18 +753,16 @@ def capture_event( :returns: An event ID. May be `None` if there is no DSN set or of if the SDK decided to discard the event for other reasons. In such situations setting `debug=True` on `init()` may help. """ - if hint is None: - hint = {} - event_id = event.get("event_id") hint = dict(hint or ()) # type: Hint - if event_id is None: - event["event_id"] = event_id = uuid.uuid4().hex if not self._should_capture(event, hint, scope): return None profile = event.pop("profile", None) + event_id = event.get("event_id") + if event_id is None: + event["event_id"] = event_id = uuid.uuid4().hex event_opt = self._prepare_event(event, hint, scope) if event_opt is None: return None @@ -812,15 +810,16 @@ def capture_event( for attachment in attachments or (): envelope.add_item(attachment.to_envelope_item()) + return_value = None if self.spotlight: self.spotlight.capture_envelope(envelope) + return_value = event_id - if self.transport is None: - return None - - self.transport.capture_envelope(envelope) + if self.transport is not None: + self.transport.capture_envelope(envelope) + return_value = event_id - return event_id + return return_value def capture_session( self, session # type: Session diff --git a/sentry_sdk/spotlight.py b/sentry_sdk/spotlight.py index 3e8072b5d8..e21bf56545 100644 --- a/sentry_sdk/spotlight.py +++ b/sentry_sdk/spotlight.py @@ -79,14 +79,22 @@ def process_exception(self, _request, exception): spotlight_url = spotlight_client.url.rsplit("/", 1)[0] try: - spotlight = ( - urllib.request.urlopen(spotlight_url).read().decode("utf-8") - ).replace("", f'') + spotlight = urllib.request.urlopen(spotlight_url).read().decode("utf-8") except urllib.error.URLError: return None else: - sentry_sdk.api.capture_exception(exception) - return HttpResponseServerError(spotlight) + event_id = sentry_sdk.api.capture_exception(exception) + return HttpResponseServerError( + spotlight.replace( + "", + ( + f'' + ''.format( + event_id=event_id + ) + ), + ) + ) except ImportError: settings = None From 2d2e5488172972498aec5c2eaf8a0ba62937e840 Mon Sep 17 00:00:00 2001 From: Daniel Szoke <7881302+szokeasaurusrex@users.noreply.github.com> Date: Tue, 8 Oct 2024 10:03:46 +0200 Subject: [PATCH 11/11] feat: Add `__notes__` support (#3620) * Add support for add_note() * Ignore non-str notes * minor tweaks --------- Co-authored-by: Arjen Nienhuis --- sentry_sdk/utils.py | 14 ++++++++++++-- tests/test_basics.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/sentry_sdk/utils.py b/sentry_sdk/utils.py index 44cb98bfed..3c86564ef8 100644 --- a/sentry_sdk/utils.py +++ b/sentry_sdk/utils.py @@ -713,11 +713,21 @@ def get_errno(exc_value): def get_error_message(exc_value): # type: (Optional[BaseException]) -> str - return ( + message = ( getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value) - ) + ) # type: str + + # __notes__ should be a list of strings when notes are added + # via add_note, but can be anything else if __notes__ is set + # directly. We only support strings in __notes__, since that + # is the correct use. + notes = getattr(exc_value, "__notes__", None) # type: object + if isinstance(notes, list) and len(notes) > 0: + message += "\n" + "\n".join(note for note in notes if isinstance(note, str)) + + return message def single_exception_from_error_tuple( diff --git a/tests/test_basics.py b/tests/test_basics.py index 139f919a68..91addc6219 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -999,3 +999,46 @@ def test_hub_current_deprecation_warning(): def test_hub_main_deprecation_warnings(): with pytest.warns(sentry_sdk.hub.SentryHubDeprecationWarning): Hub.main + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note() not supported") +def test_notes(sentry_init, capture_events): + sentry_init() + events = capture_events() + try: + e = ValueError("aha!") + e.add_note("Test 123") + e.add_note("another note") + raise e + except Exception: + capture_exception() + + (event,) = events + + assert event["exception"]["values"][0]["value"] == "aha!\nTest 123\nanother note" + + +@pytest.mark.skipif(sys.version_info < (3, 11), reason="add_note() not supported") +def test_notes_safe_str(sentry_init, capture_events): + class Note2: + def __repr__(self): + raise TypeError + + def __str__(self): + raise TypeError + + sentry_init() + events = capture_events() + try: + e = ValueError("aha!") + e.add_note("note 1") + e.__notes__.append(Note2()) # type: ignore + e.add_note("note 3") + e.__notes__.append(2) # type: ignore + raise e + except Exception: + capture_exception() + + (event,) = events + + assert event["exception"]["values"][0]["value"] == "aha!\nnote 1\nnote 3"